go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/resultdb/cli/cmd_stream.go (about)

     1  // Copyright 2020 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 cli
    16  
    17  import (
    18  	"context"
    19  	"encoding/hex"
    20  	"fmt"
    21  	"os"
    22  	"os/exec"
    23  	"os/user"
    24  	"regexp"
    25  	"strings"
    26  	"sync"
    27  	"time"
    28  
    29  	"github.com/maruel/subcommands"
    30  	"google.golang.org/grpc"
    31  	"google.golang.org/grpc/metadata"
    32  	"google.golang.org/protobuf/encoding/protojson"
    33  	"google.golang.org/protobuf/types/known/fieldmaskpb"
    34  	"google.golang.org/protobuf/types/known/structpb"
    35  
    36  	"go.chromium.org/luci/auth"
    37  	"go.chromium.org/luci/common/cli"
    38  	"go.chromium.org/luci/common/data/rand/mathrand"
    39  	"go.chromium.org/luci/common/data/strpair"
    40  	"go.chromium.org/luci/common/data/text"
    41  	"go.chromium.org/luci/common/errors"
    42  	"go.chromium.org/luci/common/flag"
    43  	"go.chromium.org/luci/common/logging"
    44  	"go.chromium.org/luci/common/system/exitcode"
    45  	"go.chromium.org/luci/hardcoded/chromeinfra"
    46  	"go.chromium.org/luci/lucictx"
    47  	"go.chromium.org/luci/server/auth/realms"
    48  
    49  	"go.chromium.org/luci/resultdb/pbutil"
    50  	pb "go.chromium.org/luci/resultdb/proto/v1"
    51  	"go.chromium.org/luci/resultdb/sink"
    52  	sinkpb "go.chromium.org/luci/resultdb/sink/proto/v1"
    53  )
    54  
    55  var matchInvalidInvocationIDChars = regexp.MustCompile(`[^a-z0-9_\-:.]`)
    56  
    57  const (
    58  	// reservePeriodSecs is how many seconds should be reserved for `rdb stream` to
    59  	// complete (out of a grace period), the rest can be given to the payload.
    60  	reservePeriodSecs = 3
    61  )
    62  
    63  // MustReturnInvURL returns a string of the Invocation URL.
    64  func MustReturnInvURL(rdbHost, invName string) string {
    65  	invID, err := pbutil.ParseInvocationName(invName)
    66  	if err != nil {
    67  		panic(err)
    68  	}
    69  
    70  	miloHost := chromeinfra.MiloDevHost
    71  	if rdbHost == chromeinfra.ResultDBHost {
    72  		miloHost = chromeinfra.MiloHost
    73  	}
    74  	return fmt.Sprintf("https://%s/ui/inv/%s", miloHost, invID)
    75  }
    76  
    77  func cmdStream(p Params) *subcommands.Command {
    78  	return &subcommands.Command{
    79  		UsageLine: `stream [flags] TEST_CMD [TEST_ARG]...`,
    80  		ShortDesc: "Run a given test command and upload the results to ResultDB",
    81  		// TODO(crbug.com/1017288): add a link to ResultSink protocol doc
    82  		LongDesc: text.Doc(`
    83  			Run a given test command, continuously collect the results over IPC, and
    84  			upload them to ResultDB. Either use the current invocation from
    85  			LUCI_CONTEXT or create/finalize a new one. Example:
    86  				rdb stream -new -realm chromium:public ./out/chrome/test/browser_tests
    87  		`),
    88  		CommandRun: func() subcommands.CommandRun {
    89  			r := &streamRun{
    90  				vars: make(map[string]string),
    91  				tags: make(strpair.Map),
    92  			}
    93  			r.baseCommandRun.RegisterGlobalFlags(p)
    94  			r.Flags.BoolVar(&r.isNew, "new", false, text.Doc(`
    95  				If true, create and use a new invocation for the test command.
    96  				If false, use the current invocation, set in LUCI_CONTEXT.
    97  			`))
    98  			r.Flags.BoolVar(&r.isIncluded, "include", false, text.Doc(`
    99  				If true with -new, the new invocation will be included
   100  				in the current invocation, set in LUCI_CONTEXT.
   101  			`))
   102  			r.Flags.StringVar(&r.realm, "realm", "", text.Doc(`
   103  				Realm to create the new invocation in. Required if -new is set,
   104  				ignored otherwise.
   105  				e.g. "chromium:public"
   106  			`))
   107  			r.Flags.StringVar(&r.testIDPrefix, "test-id-prefix", "", text.Doc(`
   108  				Prefix to prepend to the test ID of every test result.
   109  			`))
   110  			r.Flags.Var(flag.StringMap(r.vars), "var", text.Doc(`
   111  				Variant to add to every test result in "key:value" format.
   112  				If the test command adds a variant with the same key, the value given by
   113  				this flag will get overridden.
   114  			`))
   115  			r.Flags.UintVar(&r.artChannelMaxLeases, "max-concurrent-artifact-uploads",
   116  				sink.DefaultArtChannelMaxLeases, text.Doc(`
   117  				The maximum number of goroutines uploading artifacts.
   118  			`))
   119  			r.Flags.UintVar(&r.trChannelMaxLeases, "max-concurrent-test-result-uploads",
   120  				sink.DefaultTestResultChannelMaxLeases, text.Doc(`
   121  				The maximum number of goroutines uploading test results.
   122  			`))
   123  			r.Flags.StringVar(&r.testTestLocationBase, "test-location-base", "", text.Doc(`
   124  				File base to prepend to the test location file name, if the file name is a relative path.
   125  				It must start with "//".
   126  			`))
   127  			r.Flags.Var(flag.StringPairs(r.tags), "tag", text.Doc(`
   128  				Tag to add to every test result in "key:value" format.
   129  				A key can be repeated.
   130  			`))
   131  			r.Flags.BoolVar(&r.coerceNegativeDuration, "coerce-negative-duration",
   132  				false, text.Doc(`
   133  				If true, all negative durations will be coerced to 0.
   134  				If false, test results with negative durations will be rejected.
   135  			`))
   136  			r.Flags.StringVar(&r.locTagsFile, "location-tags-file", "", text.Doc(`
   137  				Path to the file that contains test location tags in JSON format. See
   138  				https://source.chromium.org/chromium/infra/infra/+/master:go/src/go.chromium.org/luci/resultdb/sink/proto/v1/location_tag.proto.
   139  			`))
   140  			r.Flags.BoolVar(&r.exonerateUnexpectedPass, "exonerate-unexpected-pass",
   141  				false, text.Doc(`
   142  				If true, any unexpected pass result will be exonerated.
   143  			`))
   144  			r.Flags.TextVar(&r.invProperties, "inv-properties", &invProperties{}, text.Doc(`
   145  				Stringified JSON object that contains structured,
   146  				domain-specific properties of the invocation.
   147  				The command will fail if properties have already been set on
   148  				the invocation (NOT ENFORCED YET).
   149  			`))
   150  			r.Flags.StringVar(&r.invPropertiesFile, "inv-properties-file", "", text.Doc(`
   151  				Similar to -inv-properties but takes a path to the file that contains the JSON object.
   152  				Cannot be used when -inv-properties is specified.
   153  			`))
   154  			r.Flags.BoolVar(&r.inheritSources, "inherit-sources", false, text.Doc(`
   155  				If true, sets that the invocation inherits the code sources tested from its
   156  				parent invocation (source_spec.inherit = true).
   157  				If false, does not alter the invocation.
   158  				Cannot be used in conjunction with -sources or -sources-file.
   159  				This command will fail if the source_spec has already been set
   160  				on the invocation (NOT ENFORCED YET).
   161  			`))
   162  			r.Flags.TextVar(&r.sources, "sources", &sources{}, text.Doc(`
   163  				JSON-serialized luci.resultdb.v1.Sources object that
   164  				contains information about the code sources tested by the
   165  				invocation.
   166  				Cannot be used in conjunction with -inherit-sources or -sources-file.
   167  				This command will fail if the source_spec has already been set
   168  				on the invocation (NOT ENFORCED YET).
   169  			`))
   170  			r.Flags.StringVar(&r.sourcesFile, "sources-file", "", text.Doc(`
   171  				Similar to -sources, but takes the path to a file that
   172  				contains the JSON-serialized luci.resultdb.v1.Sources
   173  				object.
   174  				Cannot be used in combination with -sources or -inherit-sources.
   175  			`))
   176  			r.Flags.StringVar(&r.baselineID, "baseline-id", "", text.Doc(`
   177  				Baseline identifier for this invocation, usually of the format
   178  				{buildbucket bucket}:{buildbucket builder name}.
   179  				For example, 'try:linux-rel'.
   180  			`))
   181  			return r
   182  		},
   183  	}
   184  }
   185  
   186  type invProperties struct {
   187  	*structpb.Struct
   188  }
   189  
   190  // Implements encoding.TextUnmarshaler.
   191  func (s *invProperties) UnmarshalText(text []byte) error {
   192  	// Treat empty text as nil. This indicates that the properties is not
   193  	// specified and will not be updated.
   194  	// '{}' means the properties should be set to an empty object.
   195  	if len(text) == 0 {
   196  		s.Struct = nil
   197  		return nil
   198  	}
   199  
   200  	properties := &structpb.Struct{}
   201  	if err := protojson.Unmarshal(text, properties); err != nil {
   202  		return err
   203  	}
   204  	s.Struct = properties
   205  	return nil
   206  }
   207  
   208  // Implements encoding.TextMarshaler.
   209  func (s *invProperties) MarshalText() (text []byte, err error) {
   210  	// Serialize nil struct to empty string so nil struct won't be serialized as
   211  	// '{}'.
   212  	if s.Struct == nil {
   213  		return nil, nil
   214  	}
   215  
   216  	return protojson.Marshal(s.Struct)
   217  }
   218  
   219  type sources struct {
   220  	*pb.Sources
   221  }
   222  
   223  // Implements encoding.TextUnmarshaler.
   224  func (s *sources) UnmarshalText(text []byte) error {
   225  	// Treat empty text as nil. This indicates that the code sources
   226  	// tested are not specified and will not be updated.
   227  	// '{}' means the sources should be set to an empty object.
   228  	if len(text) == 0 {
   229  		s.Sources = nil
   230  		return nil
   231  	}
   232  
   233  	sources := &pb.Sources{}
   234  	if err := protojson.Unmarshal(text, sources); err != nil {
   235  		return err
   236  	}
   237  	s.Sources = sources
   238  	return nil
   239  }
   240  
   241  // Implements encoding.TextMarshaler.
   242  func (s *sources) MarshalText() (text []byte, err error) {
   243  	// Serialize nil struct to empty string so nil struct won't be serialized as
   244  	// '{}'.
   245  	if s.Sources == nil {
   246  		return nil, nil
   247  	}
   248  
   249  	return protojson.Marshal(s.Sources)
   250  }
   251  
   252  type streamRun struct {
   253  	baseCommandRun
   254  
   255  	// flags
   256  	isNew                   bool
   257  	isIncluded              bool
   258  	realm                   string
   259  	testIDPrefix            string
   260  	testTestLocationBase    string
   261  	vars                    map[string]string
   262  	artChannelMaxLeases     uint
   263  	trChannelMaxLeases      uint
   264  	tags                    strpair.Map
   265  	coerceNegativeDuration  bool
   266  	locTagsFile             string
   267  	exonerateUnexpectedPass bool
   268  	invPropertiesFile       string
   269  	invProperties           invProperties
   270  	inheritSources          bool
   271  	sourcesFile             string
   272  	sources                 sources
   273  	baselineID              string
   274  	// TODO(ddoman): add flags
   275  	// - invocation-tag
   276  	// - log-file
   277  
   278  	invocation *lucictx.ResultDBInvocation
   279  }
   280  
   281  func (r *streamRun) validate(ctx context.Context, args []string) (err error) {
   282  	if len(args) == 0 {
   283  		return errors.Reason("missing a test command to run").Err()
   284  	}
   285  	if err := pbutil.ValidateVariant(&pb.Variant{Def: r.vars}); err != nil {
   286  		return errors.Annotate(err, "invalid variant").Err()
   287  	}
   288  	if r.realm != "" {
   289  		if err := realms.ValidateRealmName(r.realm, realms.GlobalScope); err != nil {
   290  			return errors.Annotate(err, "invalid realm").Err()
   291  		}
   292  	}
   293  	if r.invProperties.Struct != nil && r.invPropertiesFile != "" {
   294  		return errors.Reason("cannot specify both -inv-properties and -inv-properties-file at the same time").Err()
   295  	}
   296  	sourceSpecs := 0
   297  	if r.sources.Sources != nil {
   298  		sourceSpecs++
   299  	}
   300  	if r.sourcesFile != "" {
   301  		sourceSpecs++
   302  	}
   303  	if r.inheritSources {
   304  		sourceSpecs++
   305  	}
   306  	if sourceSpecs > 1 {
   307  		return errors.Reason("cannot specify more than one of -inherit-sources, -sources and -sources-file at the same time").Err()
   308  	}
   309  	return nil
   310  }
   311  
   312  func (r *streamRun) Run(a subcommands.Application, args []string, env subcommands.Env) (ret int) {
   313  	ctx := cli.GetContext(a, r, env)
   314  
   315  	if err := r.validate(ctx, args); err != nil {
   316  		return r.done(err)
   317  	}
   318  
   319  	loginMode := auth.OptionalLogin
   320  	// login is required only if it creates a new invocation.
   321  	if r.isNew {
   322  		if r.realm == "" {
   323  			return r.done(errors.Reason("-realm is required for new invocations").Err())
   324  		}
   325  		loginMode = auth.SilentLogin
   326  	}
   327  	if err := r.initClients(ctx, loginMode); err != nil {
   328  		return r.done(err)
   329  	}
   330  
   331  	// if -new is passed, create a new invocation. If not, use the existing one set in
   332  	// lucictx.
   333  	switch {
   334  	case r.isNew:
   335  		if r.isIncluded && r.resultdbCtx == nil {
   336  			return r.done(errors.Reason("missing an invocation in LUCI_CONTEXT, but -include was given").Err())
   337  		}
   338  
   339  		newInv, err := r.createInvocation(ctx, r.realm)
   340  		if err != nil {
   341  			return r.done(err)
   342  		}
   343  		fmt.Fprintf(os.Stderr, "rdb-stream: created invocation - %s\n", MustReturnInvURL(r.host, newInv.Name))
   344  		if r.isIncluded {
   345  			curInv := r.resultdbCtx.CurrentInvocation
   346  			if err := r.includeInvocation(ctx, curInv, &newInv); err != nil {
   347  				if ferr := r.finalizeInvocation(ctx); ferr != nil {
   348  					logging.Errorf(ctx, "failed to finalize the invocation: %s", ferr)
   349  				}
   350  				return r.done(err)
   351  			}
   352  			fmt.Fprintf(os.Stderr, "rdb-stream: included %q in %q\n", newInv.Name, curInv.Name)
   353  		}
   354  
   355  		// Update lucictx with the new invocation.
   356  		r.invocation = &newInv
   357  		ctx = lucictx.SetResultDB(ctx, &lucictx.ResultDB{
   358  			Hostname:          r.host,
   359  			CurrentInvocation: r.invocation,
   360  		})
   361  	case r.isIncluded:
   362  		return r.done(errors.Reason("-new is required for -include").Err())
   363  	case r.resultdbCtx == nil:
   364  		return r.done(errors.Reason("missing an invocation in LUCI_CONTEXT; use -new to create a new one").Err())
   365  	default:
   366  		if err := r.validateCurrentInvocation(); err != nil {
   367  			return r.done(err)
   368  		}
   369  		r.invocation = r.resultdbCtx.CurrentInvocation
   370  	}
   371  
   372  	invProperties, err := r.invPropertiesFromArgs(ctx)
   373  	if err != nil {
   374  		return r.done(errors.Annotate(err, "get invocation properties from arguments").Err())
   375  	}
   376  	sourceSpec, err := r.sourceSpecFromArgs(ctx)
   377  	if err != nil {
   378  		return r.done(errors.Annotate(err, "get source spec from arguments").Err())
   379  	}
   380  	if err := r.updateInvocation(ctx, invProperties, sourceSpec, r.baselineID); err != nil {
   381  		return r.done(err)
   382  	}
   383  
   384  	defer func() {
   385  		// Finalize the invocation if it was created by -new.
   386  		if r.isNew {
   387  			if err := r.finalizeInvocation(ctx); err != nil {
   388  				logging.Errorf(ctx, "failed to finalize the invocation: %s", err)
   389  				ret = r.done(err)
   390  			}
   391  			fmt.Fprintf(os.Stderr, "rdb-stream: finalized invocation - %s\n", MustReturnInvURL(r.host, r.invocation.Name))
   392  		}
   393  	}()
   394  
   395  	err = r.runTestCmd(ctx, args)
   396  	ec, ok := exitcode.Get(err)
   397  	if !ok {
   398  		logging.Errorf(ctx, "rdb-stream: failed to run the test command: %s", err)
   399  		return r.done(err)
   400  	}
   401  	logging.Infof(ctx, "rdb-stream: exiting with %d", ec)
   402  	return ec
   403  }
   404  
   405  func (r *streamRun) runTestCmd(ctx context.Context, args []string) error {
   406  	cmdCtx, cancelCmd := lucictx.TrackSoftDeadline(ctx, reservePeriodSecs*time.Second)
   407  	defer cancelCmd()
   408  
   409  	cmd := exec.CommandContext(cmdCtx, args[0], args[1:]...)
   410  	cmd.Stdin = os.Stdin
   411  	cmd.Stdout = os.Stdout
   412  	cmd.Stderr = os.Stderr
   413  	setSysProcAttr(cmd)
   414  	cmdProcMu := sync.Mutex{}
   415  
   416  	// Interrupt the subprocess if rdb-stream is interrupted or the deadline
   417  	// approaches.
   418  	// If it does not finish before the grace period expires, it will be
   419  	// SIGKILLed by the expiration of cmdCtx.
   420  	go func() {
   421  		evt := <-lucictx.SoftDeadlineDone(cmdCtx)
   422  		if evt == lucictx.ClosureEvent {
   423  			// Cleanup only.
   424  			return
   425  		}
   426  		logging.Infof(ctx, "Caught %s", evt.String())
   427  
   428  		// Prevent accessing cmd.Process while it's being started.
   429  		cmdProcMu.Lock()
   430  		defer cmdProcMu.Unlock()
   431  		if err := terminate(ctx, cmd.Process); err != nil {
   432  			logging.Warningf(ctx, "Could not terminate subprocess (%s), cancelling its context", err)
   433  			cancelCmd()
   434  			return
   435  		}
   436  		logging.Infof(ctx, "Sent termination signal to subprocess, it has ~%s to terminate", lucictx.GetDeadline(cmdCtx).GracePeriodDuration())
   437  	}()
   438  
   439  	locationTags, err := r.locationTagsFromArg(ctx)
   440  	if err != nil {
   441  		return errors.Annotate(err, "get location tags").Err()
   442  	}
   443  	// TODO(ddoman): send the logs of SinkServer to --log-file
   444  
   445  	cfg := sink.ServerConfig{
   446  		ArtChannelMaxLeases:        r.artChannelMaxLeases,
   447  		ArtifactStreamClient:       r.http,
   448  		ArtifactStreamHost:         r.host,
   449  		Recorder:                   r.recorder,
   450  		TestResultChannelMaxLeases: r.trChannelMaxLeases,
   451  
   452  		Invocation:  r.invocation.Name,
   453  		UpdateToken: r.invocation.UpdateToken,
   454  
   455  		BaseTags:                pbutil.FromStrpairMap(r.tags),
   456  		BaseVariant:             &pb.Variant{Def: r.vars},
   457  		CoerceNegativeDuration:  r.coerceNegativeDuration,
   458  		LocationTags:            locationTags,
   459  		TestLocationBase:        r.testTestLocationBase,
   460  		TestIDPrefix:            r.testIDPrefix,
   461  		ExonerateUnexpectedPass: r.exonerateUnexpectedPass,
   462  	}
   463  	return sink.Run(ctx, cfg, func(ctx context.Context, cfg sink.ServerConfig) error {
   464  		exported, err := lucictx.Export(ctx)
   465  		if err != nil {
   466  			return err
   467  		}
   468  		defer func() {
   469  			logging.Infof(ctx, "rdb-stream: the test process terminated")
   470  			exported.Close()
   471  		}()
   472  		exported.SetInCmd(cmd)
   473  		logging.Infof(ctx, "rdb-stream: starting the test command - %q", cmd.Args)
   474  
   475  		cmdProcMu.Lock()
   476  		err = cmd.Start()
   477  		cmdProcMu.Unlock()
   478  
   479  		if err != nil {
   480  			return errors.Annotate(err, "cmd.start").Err()
   481  		}
   482  		return cmd.Wait()
   483  	})
   484  }
   485  
   486  func (r *streamRun) locationTagsFromArg(ctx context.Context) (*sinkpb.LocationTags, error) {
   487  	if r.locTagsFile == "" {
   488  		return nil, nil
   489  	}
   490  	f, err := os.ReadFile(r.locTagsFile)
   491  	switch {
   492  	case os.IsNotExist(err):
   493  		logging.Warningf(ctx, "rdb-stream: %s does not exist", r.locTagsFile)
   494  		return nil, nil
   495  	case err != nil:
   496  		return nil, err
   497  	}
   498  	locationTags := &sinkpb.LocationTags{}
   499  	if err = protojson.Unmarshal(f, locationTags); err != nil {
   500  		return nil, err
   501  	}
   502  	return locationTags, nil
   503  }
   504  
   505  // invPropertiesFromArgs gets invocation-level proeprties from arguments.
   506  // If r.invProperties is set, return it.
   507  // If r.invPropertiesFile is set, parse the file and return the value.
   508  // Return nil if neither are set.
   509  func (r *streamRun) invPropertiesFromArgs(ctx context.Context) (*structpb.Struct, error) {
   510  	if r.invProperties.Struct != nil {
   511  		return r.invProperties.Struct, nil
   512  	}
   513  
   514  	if r.invPropertiesFile == "" {
   515  		return nil, nil
   516  	}
   517  
   518  	f, err := os.ReadFile(r.invPropertiesFile)
   519  	if err != nil {
   520  		return nil, errors.Annotate(err, "read file").Err()
   521  	}
   522  
   523  	properties := &structpb.Struct{}
   524  	if err = protojson.Unmarshal(f, properties); err != nil {
   525  		return nil, errors.Annotate(err, "unmarshal file").Err()
   526  	}
   527  
   528  	return properties, nil
   529  }
   530  
   531  // sourceSpecFromArgs gets the invocation source spec from arguments.
   532  // Return nil if none is set.
   533  func (r *streamRun) sourceSpecFromArgs(ctx context.Context) (*pb.SourceSpec, error) {
   534  	if r.sources.Sources != nil {
   535  		return &pb.SourceSpec{Sources: r.sources.Sources}, nil
   536  	}
   537  	if r.inheritSources {
   538  		return &pb.SourceSpec{Inherit: true}, nil
   539  	}
   540  
   541  	if r.sourcesFile == "" {
   542  		return nil, nil
   543  	}
   544  
   545  	f, err := os.ReadFile(r.sourcesFile)
   546  	if err != nil {
   547  		return nil, errors.Annotate(err, "read file").Err()
   548  	}
   549  
   550  	sources := &pb.Sources{}
   551  	if err = protojson.Unmarshal(f, sources); err != nil {
   552  		return nil, errors.Annotate(err, "unmarshal file").Err()
   553  	}
   554  
   555  	return &pb.SourceSpec{Sources: sources}, nil
   556  }
   557  
   558  func (r *streamRun) createInvocation(ctx context.Context, realm string) (ret lucictx.ResultDBInvocation, err error) {
   559  	invID, err := GenInvID(ctx)
   560  	if err != nil {
   561  		return
   562  	}
   563  
   564  	md := metadata.MD{}
   565  	resp, err := r.recorder.CreateInvocation(ctx, &pb.CreateInvocationRequest{
   566  		InvocationId: invID,
   567  		Invocation: &pb.Invocation{
   568  			Realm: realm,
   569  		},
   570  	}, grpc.Header(&md))
   571  	if err != nil {
   572  		err = errors.Annotate(err, "failed to create an invocation").Err()
   573  		return
   574  	}
   575  	tks := md.Get(pb.UpdateTokenMetadataKey)
   576  	if len(tks) == 0 {
   577  		err = errors.Reason("Missing header: %s", pb.UpdateTokenMetadataKey).Err()
   578  		return
   579  	}
   580  
   581  	ret = lucictx.ResultDBInvocation{Name: resp.Name, UpdateToken: tks[0]}
   582  	return
   583  }
   584  
   585  func (r *streamRun) includeInvocation(ctx context.Context, parent, child *lucictx.ResultDBInvocation) error {
   586  	ctx = metadata.AppendToOutgoingContext(ctx, pb.UpdateTokenMetadataKey, parent.UpdateToken)
   587  	_, err := r.recorder.UpdateIncludedInvocations(ctx, &pb.UpdateIncludedInvocationsRequest{
   588  		IncludingInvocation: parent.Name,
   589  		AddInvocations:      []string{child.Name},
   590  	})
   591  	return err
   592  }
   593  
   594  // updateInvocation sets the properties and/or source spec on the invocation.
   595  func (r *streamRun) updateInvocation(ctx context.Context, properties *structpb.Struct, sourceSpec *pb.SourceSpec, baselineID string) error {
   596  	ctx = metadata.AppendToOutgoingContext(ctx, pb.UpdateTokenMetadataKey, r.invocation.UpdateToken)
   597  	request := &pb.UpdateInvocationRequest{
   598  		Invocation: &pb.Invocation{
   599  			Name: r.invocation.Name,
   600  		},
   601  		UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{}},
   602  	}
   603  	if properties != nil {
   604  		request.Invocation.Properties = properties
   605  		request.UpdateMask.Paths = append(request.UpdateMask.Paths, "properties")
   606  	}
   607  	if sourceSpec != nil {
   608  		request.Invocation.SourceSpec = sourceSpec
   609  		request.UpdateMask.Paths = append(request.UpdateMask.Paths, "source_spec")
   610  	}
   611  	if baselineID != "" {
   612  		request.Invocation.BaselineId = baselineID
   613  		request.UpdateMask.Paths = append(request.UpdateMask.Paths, "baseline_id")
   614  	}
   615  	if len(request.UpdateMask.Paths) > 0 {
   616  		_, err := r.recorder.UpdateInvocation(ctx, request)
   617  		return err
   618  	}
   619  	return nil
   620  }
   621  
   622  // finalizeInvocation finalizes the invocation.
   623  func (r *streamRun) finalizeInvocation(ctx context.Context) error {
   624  	ctx = metadata.AppendToOutgoingContext(ctx, pb.UpdateTokenMetadataKey, r.invocation.UpdateToken)
   625  	_, err := r.recorder.FinalizeInvocation(ctx, &pb.FinalizeInvocationRequest{
   626  		Name: r.invocation.Name,
   627  	})
   628  	return err
   629  }
   630  
   631  // GenInvID generates an invocation ID, made of the username, the current timestamp
   632  // in a human-friendly format, and a random suffix.
   633  //
   634  // This can be used to generate a random invocation ID, but the creator and creation time
   635  // can be easily found.
   636  func GenInvID(ctx context.Context) (string, error) {
   637  	whoami, err := user.Current()
   638  	if err != nil {
   639  		return "", err
   640  	}
   641  	bytes := make([]byte, 8)
   642  	if _, err := mathrand.Read(ctx, bytes); err != nil {
   643  		return "", err
   644  	}
   645  
   646  	username := strings.ToLower(whoami.Username)
   647  	username = matchInvalidInvocationIDChars.ReplaceAllString(username, "")
   648  
   649  	suffix := strings.ToLower(fmt.Sprintf(
   650  		"%s-%s", time.Now().UTC().Format("2006-01-02-15-04-00"),
   651  		// Note: cannot use base64 because not all of its characters are allowed
   652  		// in invocation IDs.
   653  		hex.EncodeToString(bytes)))
   654  
   655  	// An invocation ID can contain up to 100 ascii characters that conform to the regex,
   656  	return fmt.Sprintf("u-%.*s-%s", 100-len(suffix), username, suffix), nil
   657  }