diff --git a/cmd/gomote/create.go b/cmd/gomote/create.go index 8db5f31db3..87ddb2ae0e 100644 --- a/cmd/gomote/create.go +++ b/cmd/gomote/create.go @@ -78,6 +78,10 @@ func builders() (bt []builderType) { } func legacyCreate(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("create", flag.ContinueOnError) fs.Usage = func() { @@ -133,6 +137,10 @@ func legacyCreate(args []string) error { } func create(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("create", flag.ContinueOnError) fs.Usage = func() { diff --git a/cmd/gomote/destroy.go b/cmd/gomote/destroy.go index 2337b3edf2..db485ff2bc 100644 --- a/cmd/gomote/destroy.go +++ b/cmd/gomote/destroy.go @@ -16,6 +16,10 @@ import ( ) func legacyDestroy(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("destroy", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "destroy usage: gomote destroy ") @@ -53,6 +57,10 @@ func legacyDestroy(args []string) error { } func destroy(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("destroy", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "destroy usage: gomote destroy ") diff --git a/cmd/gomote/get.go b/cmd/gomote/get.go index 98058c6d1b..e9d7aafb0f 100644 --- a/cmd/gomote/get.go +++ b/cmd/gomote/get.go @@ -18,6 +18,10 @@ import ( // legacyGetTar a .tar.gz func legacyGetTar(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("get", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "gettar usage: gomote gettar [get-opts] ") @@ -48,6 +52,10 @@ func legacyGetTar(args []string) error { // getTar a .tar.gz func getTar(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("get", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "gettar usage: gomote gettar [get-opts] ") diff --git a/cmd/gomote/gomote.go b/cmd/gomote/gomote.go index 193abe48ad..c50d1435b5 100644 --- a/cmd/gomote/gomote.go +++ b/cmd/gomote/gomote.go @@ -36,6 +36,7 @@ To list the subcommands, run "gomote" without arguments: rm delete files or directories rdp RDP (Remote Desktop Protocol) to a Windows buildlet run run a command on a buildlet + group manage gomote groups (v2 only) ssh ssh to a buildlet To list all the builder types available, run "create" with no arguments: @@ -71,6 +72,7 @@ The "gomote run" command has many of its own flags: -system run inside the system, and not inside the workdir; this is implicit if cmd starts with '/' + # Debugging buildlets directly Using "gomote create" contacts the build coordinator @@ -95,11 +97,13 @@ import ( "golang.org/x/build/buildlet" "golang.org/x/build/internal/gomote/protos" "golang.org/x/build/internal/iapclient" + "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) var ( - buildEnv *buildenv.Environment + buildEnv *buildenv.Environment + activeGroup *groupData ) type command struct { @@ -148,6 +152,7 @@ func registerCommands(version int) { registerCommand("create", "create a buildlet; with no args, list types of buildlets", create) registerCommand("destroy", "destroy a buildlet", destroy) registerCommand("gettar", "extract a tar.gz from a buildlet", getTar) + registerCommand("group", "manage groups of instances", group) registerCommand("ls", "list the contents of a directory on a buildlet", ls) registerCommand("list", "list active buildlets", list) registerCommand("ping", "test whether a buildlet is alive and reachable ", ping) @@ -174,6 +179,7 @@ func registerCommands(version int) { registerCommand("rdp", "RDP (Remote Desktop Protocol) to a Windows buildlet", rdp) registerCommand("rm", "delete files or directories", legacyRm) registerCommand("run", "run a command on a buildlet", legacyRun) + registerCommand("group", "manage gomote groups (v2 only)", group) registerCommand("ssh", "ssh to a buildlet", legacySSH) } @@ -182,6 +188,8 @@ var ( ) func main() { + // Set up and parse global flags. + groupName := flag.String("group", os.Getenv("GOMOTE_GROUP"), "name of the gomote group to apply commands to (default is $GOMOTE_GROUP)") buildlet.RegisterFlags() version := 2 if vs := os.Getenv("GOMOTE_VERSION"); vs != "" { @@ -196,19 +204,40 @@ func main() { registerCommands(version) flag.Usage = usage flag.Parse() - buildEnv = buildenv.FromFlags() args := flag.Args() if len(args) == 0 { usage() } + + // Set up globals. + buildEnv = buildenv.FromFlags() + if *groupName != "" { + var err error + activeGroup, err = loadGroup(*groupName) + if os.Getenv("GOMOTE_GROUP") != *groupName { + // Only fail hard since it was specified by the flag. + if err != nil { + fmt.Fprintf(os.Stderr, "Failure: %v\n", err) + usage() + } + } else { + // With a valid group from GOMOTE_GROUP, + // make it explicit to the user that we're going + // ahead with it. We don't need this with the flag + // because it's explicit. + if err == nil { + fmt.Fprintf(os.Stderr, "# Using group %q from GOMOTE_GROUP\n", *groupName) + } + } + } + cmdName := args[0] cmd, ok := commands[cmdName] if !ok { fmt.Fprintf(os.Stderr, "Unknown command %q\n", cmdName) usage() } - err := cmd.run(args[1:]) - if err != nil { + if err := cmd.run(args[1:]); err != nil { logAndExitf("Error running %s: %v\n", cmdName, err) } } @@ -233,3 +262,7 @@ func logAndExitf(format string, v ...interface{}) { func statusFromError(err error) string { return status.Convert(err).Message() } + +func instanceDoesNotExist(err error) bool { + return status.Code(err) == codes.NotFound +} diff --git a/cmd/gomote/group.go b/cmd/gomote/group.go new file mode 100644 index 0000000000..6ec40fa8a9 --- /dev/null +++ b/cmd/gomote/group.go @@ -0,0 +1,292 @@ +// Copyright 2022 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + + "golang.org/x/build/internal/gomote/protos" +) + +func group(args []string) error { + cm := map[string]struct { + run func([]string) error + desc string + }{ + "create": {createGroup, "create a new group"}, + "destroy": {destroyGroup, "destroy an existing group (does not destroy gomotes)"}, + "add": {addToGroup, "add an existing instance to a group"}, + "remove": {removeFromGroup, "remove an existing instance from a group"}, + "list": {listGroups, "list existing groups and their details"}, + } + if len(args) == 0 { + var cmds []string + for cmd := range cm { + cmds = append(cmds, cmd) + } + sort.Strings(cmds) + fmt.Fprintf(os.Stderr, "Usage of gomote group: gomote [global-flags] group [cmd-flags]\n\n") + fmt.Fprintf(os.Stderr, "Commands:\n\n") + for _, name := range cmds { + fmt.Fprintf(os.Stderr, " %-8s %s\n", name, cm[name].desc) + } + fmt.Fprintln(os.Stderr) + os.Exit(1) + } + subCmd := args[0] + sc, ok := cm[subCmd] + if !ok { + return fmt.Errorf("unknown sub-command %q\n", subCmd) + } + return sc.run(args[1:]) +} + +func createGroup(args []string) error { + usage := func() { + fmt.Fprintln(os.Stderr, "group create usage: gomote group create ") + os.Exit(1) + } + if len(args) != 1 { + usage() + } + name := args[0] + if _, err := loadGroup(name); err == nil { + return fmt.Errorf("group %q already exists", name) + } + if err := storeGroup(&groupData{ + Name: name, + }); err != nil { + return err + } + return nil +} + +func destroyGroup(args []string) error { + usage := func() { + fmt.Fprintln(os.Stderr, "group destroy usage: gomote group destroy ") + os.Exit(1) + } + if len(args) != 1 { + usage() + } + name := args[0] + _, err := loadGroup(name) + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("group %q does not exist", name) + } else if err != nil { + return fmt.Errorf("loading group %q: %w", name, err) + } + if err := deleteGroup(name); err != nil { + return err + } + if os.Getenv("GOMOTE_GROUP") == name { + fmt.Fprintln(os.Stderr, "You may wish to now clear GOMOTE_GROUP.") + } + return nil +} + +func addToGroup(args []string) error { + usage := func() { + fmt.Fprintln(os.Stderr, "group add usage: gomote group add [instances ...]") + os.Exit(1) + } + if len(args) == 0 { + usage() + } + if activeGroup == nil { + fmt.Fprintln(os.Stderr, "No active group found. Use -group or GOMOTE_GROUP.") + usage() + } + for _, inst := range args { + ctx := context.Background() + client := gomoteServerClient(ctx) + _, err := client.InstanceAlive(ctx, &protos.InstanceAliveRequest{ + GomoteId: inst, + }) + if err != nil { + return fmt.Errorf("instance %q: %s", inst, statusFromError(err)) + } + activeGroup.Instances = append(activeGroup.Instances, inst) + } + return storeGroup(activeGroup) +} + +func removeFromGroup(args []string) error { + usage := func() { + fmt.Fprintln(os.Stderr, "group add usage: gomote group add [instances ...]") + os.Exit(1) + } + if len(args) == 0 { + usage() + } + if activeGroup == nil { + fmt.Fprintln(os.Stderr, "No active group found. Use -group or GOMOTE_GROUP.") + usage() + } + newInstances := make([]string, 0, len(activeGroup.Instances)) + for _, inst := range activeGroup.Instances { + remove := false + for _, rmInst := range args { + if inst == rmInst { + remove = true + break + } + } + if remove { + continue + } + newInstances = append(newInstances, inst) + } + activeGroup.Instances = newInstances + return storeGroup(activeGroup) +} + +func listGroups(args []string) error { + usage := func() { + fmt.Fprintln(os.Stderr, "group list usage: gomote group list") + os.Exit(1) + } + if len(args) != 0 { + usage() + } + dir, err := groupDir() + if err != nil { + return fmt.Errorf("acquiring group directory: %w", err) + } + matches, _ := filepath.Glob(filepath.Join(dir, "*.json")) + // N.B. Glob ignores I/O errors, so no matches also means the directory + // does not exist. + emit := func(name, inst string) { + fmt.Printf("%s\t%s\t\n", name, inst) + } + emit("Name", "Instances") + for _, match := range matches { + g, err := loadGroupFromFile(match) + if err != nil { + return fmt.Errorf("reading group file for %q: %w", match, err) + } + sort.Strings(g.Instances) + emitted := false + for _, inst := range g.Instances { + if !emitted { + emit(g.Name, inst) + } else { + emit("", inst) + } + emitted = true + } + if !emitted { + emit(g.Name, "(none)") + } + } + if len(matches) == 0 { + fmt.Println("(none)") + } + return nil +} + +type groupData struct { + // User-provided name of the group. + Name string `json:"name"` + + // Instances is a list of instances in the group. + Instances []string `json:"instances"` +} + +func loadGroup(name string) (*groupData, error) { + fname, err := groupFilePath(name) + if err != nil { + return nil, fmt.Errorf("loading group %q: %w", name, err) + } + g, err := loadGroupFromFile(fname) + if err != nil { + return nil, fmt.Errorf("loading group %q: %w", name, err) + } + return g, nil +} + +func loadGroupFromFile(fname string) (*groupData, error) { + f, err := os.Open(fname) + if err != nil { + return nil, err + } + defer f.Close() + g := new(groupData) + if err := json.NewDecoder(f).Decode(g); err != nil { + return nil, err + } + // On every load, ping for liveness and prune. + // + // Otherwise, we can get into situations where we sometimes + // don't have an accurate record. + newInstances := make([]string, 0, len(g.Instances)) + for _, inst := range g.Instances { + ctx := context.Background() + client := gomoteServerClient(ctx) + _, err := client.InstanceAlive(ctx, &protos.InstanceAliveRequest{ + GomoteId: inst, + }) + if instanceDoesNotExist(err) { + continue + } else if err != nil { + return nil, err + } + newInstances = append(newInstances, inst) + } + g.Instances = newInstances + return g, storeGroup(g) +} + +func storeGroup(data *groupData) error { + fname, err := groupFilePath(data.Name) + if err != nil { + return fmt.Errorf("storing group %q: %w", data.Name, err) + } + if err := os.MkdirAll(filepath.Dir(fname), 0755); err != nil { + return fmt.Errorf("storing group %q: %w", data.Name, err) + } + f, err := os.Create(fname) + if err != nil { + return fmt.Errorf("storing group %q: %w", data.Name, err) + } + defer f.Close() + if err := json.NewEncoder(f).Encode(data); err != nil { + return fmt.Errorf("storing group %q: %w", data.Name, err) + } + return nil +} + +func deleteGroup(name string) error { + fname, err := groupFilePath(name) + if err != nil { + return fmt.Errorf("deleting group %q: %w", name, err) + } + if err := os.Remove(fname); err != nil { + return fmt.Errorf("deleting group %q: %w", name, err) + } + return nil +} + +func groupFilePath(name string) (string, error) { + dir, err := groupDir() + if err != nil { + return "", err + } + return filepath.Join(dir, fmt.Sprintf("%s.json", name)), nil +} + +func groupDir() (string, error) { + cfgDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(cfgDir, "gomote", "groups"), nil +} diff --git a/cmd/gomote/ls.go b/cmd/gomote/ls.go index ae7831b21d..f1f6ca6feb 100644 --- a/cmd/gomote/ls.go +++ b/cmd/gomote/ls.go @@ -16,6 +16,10 @@ import ( ) func legacyLs(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("ls", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "ls usage: gomote ls [-R] [dir]") @@ -52,6 +56,10 @@ func legacyLs(args []string) error { } func ls(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("ls", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "ls usage: gomote ls [-R] [dir]") diff --git a/cmd/gomote/ping.go b/cmd/gomote/ping.go index 879df5c897..2ea417d20a 100644 --- a/cmd/gomote/ping.go +++ b/cmd/gomote/ping.go @@ -14,6 +14,10 @@ import ( ) func legacyPing(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("ping", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "ping usage: gomote ping [--status] ") @@ -44,6 +48,10 @@ func legacyPing(args []string) error { } func ping(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("ping", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "ping usage: gomote ping [--status] ") diff --git a/cmd/gomote/push.go b/cmd/gomote/push.go index f914d0f384..520b29ec24 100644 --- a/cmd/gomote/push.go +++ b/cmd/gomote/push.go @@ -27,6 +27,10 @@ import ( ) func legacyPush(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("push", flag.ContinueOnError) var dryRun bool fs.BoolVar(&dryRun, "dry-run", false, "print what would be done only") @@ -37,18 +41,10 @@ func legacyPush(args []string) error { } fs.Parse(args) - goroot := os.Getenv("GOROOT") - if goroot == "" { - slurp, err := exec.Command("go", "env", "GOROOT").Output() - if err != nil { - return fmt.Errorf("failed to get GOROOT from go env: %v", err) - } - goroot = strings.TrimSpace(string(slurp)) - if goroot == "" { - return errors.New("Failed to get $GOROOT from environment or go env") - } + goroot, err := getGOROOT() + if err != nil { + return err } - goroot = filepath.Clean(goroot) if fs.NArg() != 1 { fs.Usage() @@ -294,6 +290,10 @@ func legacyPush(args []string) error { } func push(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("push", flag.ContinueOnError) var dryRun bool fs.BoolVar(&dryRun, "dry-run", false, "print what would be done only") @@ -304,18 +304,10 @@ func push(args []string) error { } fs.Parse(args) - goroot := os.Getenv("GOROOT") - if goroot == "" { - slurp, err := exec.Command("go", "env", "GOROOT").Output() - if err != nil { - return fmt.Errorf("failed to get GOROOT from go env: %v", err) - } - goroot = strings.TrimSpace(string(slurp)) - if goroot == "" { - return errors.New("Failed to get $GOROOT from environment or go env") - } + goroot, err := getGOROOT() + if err != nil { + return err } - goroot = filepath.Clean(goroot) if fs.NArg() != 1 { fs.Usage() @@ -669,3 +661,19 @@ func fileSHA1(path string) (string, error) { } return fmt.Sprintf("%x", s1.Sum(nil)), nil } + +func getGOROOT() (string, error) { + goroot := os.Getenv("GOROOT") + if goroot == "" { + slurp, err := exec.Command("go", "env", "GOROOT").Output() + if err != nil { + return "", fmt.Errorf("failed to get GOROOT from go env: %v", err) + } + goroot = strings.TrimSpace(string(slurp)) + if goroot == "" { + return "", errors.New("Failed to get $GOROOT from environment or go env") + } + } + goroot = filepath.Clean(goroot) + return goroot, nil +} diff --git a/cmd/gomote/put.go b/cmd/gomote/put.go index dd272720c1..b6d03cf08c 100644 --- a/cmd/gomote/put.go +++ b/cmd/gomote/put.go @@ -25,6 +25,10 @@ import ( // legacyPutTar a .tar.gz func legacyPutTar(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("put", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "puttar usage: gomote puttar [put-opts] [tar.gz file or '-' for stdin]") @@ -95,6 +99,10 @@ func legacyPutTar(args []string) error { // putTar a .tar.gz func putTar(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("put", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "puttar usage: gomote puttar [put-opts] [tar.gz file or '-' for stdin]") @@ -195,6 +203,10 @@ func putTar(args []string) error { // put go1.4 in the workdir func put14(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("put14", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "put14 usage: gomote put14 ") @@ -221,6 +233,10 @@ func put14(args []string) error { // putBootstrap places the bootstrap version of go in the workdir func putBootstrap(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("putbootstrap", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "putbootstrap usage: gomote putbootstrap ") @@ -248,6 +264,10 @@ func putBootstrap(args []string) error { // legacyPut single file func legacyPut(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("put", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "put usage: gomote put [put-opts] [destination]") @@ -310,6 +330,10 @@ func legacyPut(args []string) error { // put single file func put(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("put", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "put usage: gomote put [put-opts] [destination]") diff --git a/cmd/gomote/rdp.go b/cmd/gomote/rdp.go index 84ef85b434..170660a681 100644 --- a/cmd/gomote/rdp.go +++ b/cmd/gomote/rdp.go @@ -21,6 +21,10 @@ import ( const rdpPort = 3389 func rdp(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("rdp", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "rdp usage: gomote rdp [--listen=...] ") diff --git a/cmd/gomote/rm.go b/cmd/gomote/rm.go index 9b427dff6d..545c89ede2 100644 --- a/cmd/gomote/rm.go +++ b/cmd/gomote/rm.go @@ -14,6 +14,10 @@ import ( ) func legacyRm(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("rm", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "rm usage: gomote rm +") @@ -37,6 +41,10 @@ func legacyRm(args []string) error { } func rm(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("rm", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "rm usage: gomote rm +") diff --git a/cmd/gomote/run.go b/cmd/gomote/run.go index e3dce9f7e6..d84e06e321 100644 --- a/cmd/gomote/run.go +++ b/cmd/gomote/run.go @@ -21,6 +21,10 @@ import ( ) func legacyRun(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "run usage: gomote run [run-opts] [args...]") @@ -108,6 +112,10 @@ func (ss *stringSlice) Set(v string) error { } func run(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not yet support groups") + } + fs := flag.NewFlagSet("run", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "run usage: gomote run [run-opts] [args...]") diff --git a/cmd/gomote/ssh.go b/cmd/gomote/ssh.go index 21f9f113d9..f71586c3b7 100644 --- a/cmd/gomote/ssh.go +++ b/cmd/gomote/ssh.go @@ -22,6 +22,10 @@ import ( ) func legacySSH(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("ssh", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "ssh usage: gomote ssh ") @@ -60,6 +64,10 @@ func legacySSH(args []string) error { } func ssh(args []string) error { + if activeGroup != nil { + return fmt.Errorf("command does not support groups") + } + fs := flag.NewFlagSet("ssh", flag.ContinueOnError) fs.Usage = func() { fmt.Fprintln(os.Stderr, "ssh usage: gomote ssh ")