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 }