go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/luciexe/invoke/options.go (about)

     1  // Copyright 2019 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  // Package invoke implements the process of invoking a 'luciexe' compatible
    16  // subprocess, but without setting up any of the 'host' requirements (like
    17  // a Logdog Butler or LUCI Auth).
    18  //
    19  // See go.chromium.org/luci/luciexe for details on the protocol.
    20  package invoke
    21  
    22  import (
    23  	"context"
    24  	"io"
    25  	"io/ioutil"
    26  	"os"
    27  	"path/filepath"
    28  	"strings"
    29  
    30  	"github.com/golang/protobuf/ptypes"
    31  
    32  	bbpb "go.chromium.org/luci/buildbucket/proto"
    33  	"go.chromium.org/luci/common/clock"
    34  	"go.chromium.org/luci/common/errors"
    35  	"go.chromium.org/luci/common/system/environ"
    36  	"go.chromium.org/luci/logdog/client/butlerlib/bootstrap"
    37  	"go.chromium.org/luci/logdog/client/butlerlib/streamclient"
    38  	"go.chromium.org/luci/logdog/common/types"
    39  	"go.chromium.org/luci/lucictx"
    40  	"go.chromium.org/luci/luciexe"
    41  )
    42  
    43  // Options represents settings to use when Start'ing a luciexe.
    44  //
    45  // All values here have defaults, so Start can accept `nil`.
    46  type Options struct {
    47  	// This should be in Build.Step.Name format, i.e. "parent|parent|leaf".
    48  	//
    49  	// Namespace is used as:
    50  	//   * The Step name of the enclosing Build Step.
    51  	//   * Generating the new LOGDOG_NAMESPACE in the environment for the
    52  	//     subprocess. Non-StreamName characters are replaced with '_',
    53  	//     and if the first character of a segment is not a character, "s_" is
    54  	//     prpended. i.e. if the current LOGDOG_NAMESPACE is "u", and this
    55  	//     namespace is "x|!!cool!!|z", then LOGDOG_NAMESPACE for the
    56  	//     subprocess will be "u/x/s___cool__/z".
    57  	//     * The LUCI Executable host process will assert that all logdog stream
    58  	//       names mentioned in the subprocess's Build are relative to this.
    59  	//   * The basis for the stdout/stderr Logdog streams for the subprocess.
    60  	//   * The LUCI Executable host process will adjust the subprocess's step
    61  	//     names to be relative to this. i.e. if the subprocess emits a step "a|b"
    62  	//     and the Namespace is "x|y|z", then the step will show up as "x|y|z|a|b"
    63  	//     on the overall Build.
    64  	//
    65  	// TODO(iannucci): Logdog stream names are restricted because they show up in
    66  	// URLs on the logdog service. If the logdog service instead uses URL encoding
    67  	// of the stream name, then StreamName could be expanded to allow all
    68  	// characters except for "/". At some later point we could potentially change
    69  	// the separator from "/" to "|" to match Buildbucket semantics.
    70  	//
    71  	// TODO(iannucci): Find a way to have logdog stream name namespacing be more
    72  	// transparent (i.e. without needing explicit cooperation from the logdog
    73  	// client library via LOGDOG_NAMESPACE envvar).
    74  	//
    75  	// Default: The Namespace is empty. This is ONLY useful when writing
    76  	// a transparent wrapper for a luciexe which doesn't, itself, implement the
    77  	// luciexe protocol. If Namespace is empty, then Subprocess.Step will be
    78  	// `nil`.
    79  	Namespace string
    80  
    81  	// The base dir where all sub-directories (i.e. workdir, tempdir, etc.)
    82  	// will be created under (i.e. the `cwd` for invoked luciexe will be
    83  	// `$BaseDir/w`). If specified, this must exist and be a directory.
    84  	//
    85  	// If empty, a random directory under os.TempDir will be used.
    86  	BaseDir string
    87  
    88  	// Absolute path to the cache base directory. This must exist and be
    89  	// a directory.
    90  	//
    91  	// Default: If LUCI_CONFIG['lucictx']['cache_dir'] is set, it will be passed
    92  	// through unchanged. Otherwise a new empty directory is allocated which will
    93  	// be destroyed on the subprocess's completion.
    94  	CacheDir string
    95  
    96  	// If set, the subprocess's final Build message will be collected and returned
    97  	// from Wait.
    98  	//
    99  	// If CollectOutput is specified, but CollectOutputPath is not, a temporary
   100  	// path will be seleceted and destroyed on the subprocess's completion.
   101  	CollectOutput bool
   102  
   103  	// If set, will be used as the path to output the subprocess's final Build
   104  	// state. Must end with one of {'.pb', '.json', '.textpb'}. This must be
   105  	// a path to a non-existent file (i.e. parent must be a directory).
   106  	//
   107  	// If CollectOutputPath is specified, but CollectOutput is not, the subprocess
   108  	// will be instructed to dump the result to this path, but we won't attempt to
   109  	// parse or validate it in any way, and Wait will return a nil `output`.
   110  	CollectOutputPath string
   111  
   112  	// A replacement environment for the Options.
   113  	//
   114  	// If this is not specified, the current process's environment is inherited.
   115  	//
   116  	// The following environment variables will be ignored from this `Env`:
   117  	//   * LOGDOG_NAMESPACE
   118  	//   * LUCI_CONTEXT
   119  	Env environ.Env
   120  }
   121  
   122  // launchOptions is a 'digested' form of Options, used for starting
   123  // a subprocess.
   124  type launchOptions struct {
   125  	// lctx is the exported LUCI_CONTEXT object must be Close()'d by the caller of
   126  	// Options.rationalize. It's already been set in `env`.
   127  	lctx lucictx.Exported
   128  
   129  	// step is the constructed Step object for the user of the invoke library. It
   130  	// may be nil if Namespace was omitted.
   131  	step *bbpb.Step
   132  
   133  	// workDir is the working directory for the invoked luciexe.
   134  	workDir string
   135  
   136  	// args are the CLI arguments to the luciexe.
   137  	args []string
   138  
   139  	// These are the open streams ready to attach to the subprocess.
   140  	stdout io.WriteCloser
   141  	stderr io.WriteCloser
   142  
   143  	// collectPath, if set, is the build file to read after the completion of the
   144  	// subprocess.
   145  	collectPath string
   146  
   147  	// env is an environment suitable to run the luciexe in.
   148  	env environ.Env
   149  }
   150  
   151  func (o *Options) prepNamespace(ctx context.Context, lo *launchOptions) error {
   152  	if o.Namespace == "" {
   153  		return nil
   154  	}
   155  
   156  	var bits []string
   157  	bits = append(bits, strings.Split(o.Namespace, "|")...)
   158  	relNS, _ := types.MakeStreamName("s_", bits...)
   159  
   160  	// The $LOGDOG_NAMESPACE envvar is currently cumulative, even though the
   161  	// Log.Url field is relative.
   162  	fullNS := string(relNS)
   163  	if curNS := lo.env.Get(luciexe.LogdogNamespaceEnv); curNS != "" {
   164  		fullNS = strings.Join([]string{curNS, fullNS}, "/")
   165  	}
   166  	lo.env.Set(luciexe.LogdogNamespaceEnv, fullNS)
   167  
   168  	startTime, err := ptypes.TimestampProto(clock.Now(ctx))
   169  	if err != nil {
   170  		return errors.Annotate(err, "invalid StartTime").Err()
   171  	}
   172  	lo.step = &bbpb.Step{
   173  		Name:      o.Namespace,
   174  		StartTime: startTime,
   175  		Status:    bbpb.Status_STARTED,
   176  		Logs: []*bbpb.Log{
   177  			{
   178  				Name: "stdout",
   179  				Url:  string(relNS.Concat("stdout")),
   180  			},
   181  			{
   182  				Name: "stderr",
   183  				Url:  string(relNS.Concat("stderr")),
   184  			},
   185  		},
   186  		MergeBuild: &bbpb.Step_MergeBuild{
   187  			FromLogdogStream: string(relNS.Concat(luciexe.BuildProtoStreamSuffix)),
   188  		},
   189  	}
   190  	return nil
   191  }
   192  
   193  func (o *Options) prepCacheDir(ctx context.Context, cdir string, lo *launchOptions) (newCtx context.Context, err error) {
   194  	newCtx = ctx // so we don't trip up our caller
   195  	newDir := o.CacheDir
   196  
   197  	if newDir == "" {
   198  		if curval := lucictx.GetLUCIExe(ctx); curval != nil && curval.CacheDir == "" {
   199  			err = errors.New(
   200  				`$LUCI_CONTEXT["luciexe"] is set, but "cache_dir" is empty`)
   201  			return
   202  		}
   203  		newDir = cdir
   204  	} else {
   205  		if newDir, err = filepath.Abs(newDir); err != nil {
   206  			return nil, errors.Annotate(err, "resolving CacheDir").Err()
   207  		}
   208  		if err = checkDirExists(newDir); err != nil {
   209  			return nil, errors.Annotate(err, "checking CacheDir").Err()
   210  		}
   211  	}
   212  
   213  	newCtx = lucictx.SetLUCIExe(ctx, &lucictx.LUCIExe{CacheDir: newDir})
   214  	lo.lctx, err = lucictx.Export(newCtx)
   215  	lo.lctx.SetInEnviron(lo.env)
   216  	return
   217  }
   218  
   219  func (o *Options) prepCollection(outDir string, lo *launchOptions) error {
   220  	if !o.CollectOutput && o.CollectOutputPath == "" {
   221  		return nil
   222  	}
   223  
   224  	collect := o.CollectOutputPath
   225  	if collect == "" {
   226  		collect = filepath.Join(outDir, "out"+luciexe.BuildFileCodecBinary.FileExtension())
   227  	} else {
   228  		if err := checkDirExists(filepath.Dir(collect)); err != nil {
   229  			return errors.Annotate(err, "checking CollectOutputPath's parent").Err()
   230  		}
   231  
   232  		if _, err := os.Stat(collect); !os.IsNotExist(err) {
   233  			return errors.Reason("CollectOutputPath points to an existing file: %q", collect).Err()
   234  		}
   235  	}
   236  
   237  	if _, err := luciexe.BuildFileCodecForPath(collect); err != nil {
   238  		return errors.Annotate(err, "CollectOutputPath").Err()
   239  	}
   240  
   241  	lo.args = []string{luciexe.OutputCLIArg, collect}
   242  	lo.collectPath = collect
   243  	return nil
   244  }
   245  
   246  type dirs struct {
   247  	// May or may not be used; for simplicity we always create them under dirs,
   248  	// but rationalize may override them if the user specified a specific location
   249  	// for them in Options.
   250  	cacheDir         string
   251  	collectOutputDir string
   252  
   253  	// always used
   254  	tempDir string
   255  	workDir string
   256  }
   257  
   258  func (o *Options) mkdirs() (ret dirs, err error) {
   259  	base := o.BaseDir
   260  	if base == "" {
   261  		if base, err = ioutil.TempDir("", ""); err != nil {
   262  			return
   263  		}
   264  	}
   265  	if err = checkDirExists(base); err != nil {
   266  		return ret, errors.Annotate(err, "checking BaseDir").Err()
   267  	}
   268  
   269  	// maybeMkdir attempts to make the dir named `dirname` under `base`,
   270  	// annotating the error with `friendlyName` as long as `err` is nil.
   271  	//
   272  	// Updates `err` with the result of the mkdir call as a side effect.
   273  	maybeMkdir := func(out *string, dirname, friendlyName string) {
   274  		if err == nil {
   275  			*out = filepath.Join(base, dirname)
   276  			err = errors.Annotate(os.Mkdir(*out, 0777), "preparing %q", friendlyName).Err()
   277  		}
   278  	}
   279  
   280  	maybeMkdir(&ret.cacheDir, "c", "cache-dir")
   281  	maybeMkdir(&ret.collectOutputDir, "o", "output-dir")
   282  	maybeMkdir(&ret.tempDir, "t", "temp-dir")
   283  	maybeMkdir(&ret.workDir, "w", "work-dir")
   284  
   285  	return
   286  }
   287  
   288  func (lo *launchOptions) prepStdio(ctx context.Context) error {
   289  	// NOTE: bootstrapping doesn't do any 'write' actions to the logdog state and
   290  	// is a fancy way of reading a couple of envvars and building a struct (i.e.
   291  	// this is quite cheap).
   292  	bs, err := bootstrap.GetFromEnv(lo.env) // picks up Namespace
   293  	switch err {
   294  	case nil:
   295  	case bootstrap.ErrNotBootstrapped:
   296  		return errors.New("Logdog Butler environment required")
   297  	default:
   298  		return errors.Annotate(err, "bootstrapping logdog client").Err()
   299  	}
   300  
   301  	openStream := func(name string) (ret io.WriteCloser, err error) {
   302  		ret, err = bs.Client.NewStream(
   303  			ctx, types.StreamName(name), streamclient.ForProcess())
   304  		err = errors.Annotate(err, "opening %q", name).Err()
   305  		return
   306  	}
   307  
   308  	if lo.stdout, err = openStream("stdout"); err != nil {
   309  		return err
   310  	}
   311  	if lo.stderr, err = openStream("stderr"); err != nil {
   312  		lo.stdout.Close()
   313  		return err
   314  	}
   315  	return nil
   316  }
   317  
   318  // rationalize converts from a set of requested Options to a usable
   319  // launchOptions object.
   320  func (o *Options) rationalize(ctx context.Context) (ret launchOptions, newCtx context.Context, err error) {
   321  	if o == nil {
   322  		o = &Options{}
   323  	}
   324  	if o.Env.Len() != 0 {
   325  		ret.env = o.Env.Clone()
   326  	} else {
   327  		ret.env = environ.System()
   328  	}
   329  
   330  	var d dirs
   331  	if d, err = o.mkdirs(); err != nil {
   332  		return
   333  	}
   334  	ret.workDir = d.workDir
   335  	for _, key := range luciexe.TempDirEnvVars {
   336  		ret.env.Set(key, d.tempDir)
   337  	}
   338  
   339  	if newCtx, err = o.prepCacheDir(ctx, d.cacheDir, &ret); err != nil {
   340  		err = errors.Annotate(err, "preparing cachedir").Err()
   341  		return
   342  	}
   343  
   344  	if err = o.prepNamespace(newCtx, &ret); err != nil {
   345  		err = errors.Annotate(err, "preparing namespace").Err()
   346  		return
   347  	}
   348  
   349  	if err = o.prepCollection(d.collectOutputDir, &ret); err != nil {
   350  		err = errors.Annotate(err, "preparing collection").Err()
   351  		return
   352  	}
   353  
   354  	if err = ret.prepStdio(newCtx); err != nil {
   355  		err = errors.Annotate(err, "preparing outputs").Err()
   356  		return
   357  	}
   358  
   359  	return
   360  }
   361  
   362  // checkDirExists returns error if the given path is not an
   363  // existing directory.
   364  func checkDirExists(path string) error {
   365  	fInfo, err := os.Stat(path)
   366  	if err != nil {
   367  		if os.IsNotExist(err) {
   368  			return errors.Reason("dir does not exist: %q", path).Err()
   369  		}
   370  		return errors.Annotate(err, "statting path: %q", path).Err()
   371  	}
   372  	if !fInfo.IsDir() {
   373  		return errors.Reason("path is not a directory: %q", path).Err()
   374  	}
   375  	return nil
   376  }