| // This program updates the GCE machine definitions in //skolo/ansible/ssh.cfg based on machine |
| // descriptions returned by the "gcloud" command. It assumes that the gcloud command is installed |
| // and that the user has the necessary credentials. |
| package main |
| |
| import ( |
| "bufio" |
| "context" |
| "encoding/csv" |
| "fmt" |
| "io" |
| "os" |
| "strings" |
| |
| "go.skia.org/infra/go/executil" |
| "go.skia.org/infra/go/skerr" |
| "go.skia.org/infra/go/util" |
| ) |
| |
| const ( |
| autogeneratedBlockBegin = "# BEGIN GCE MACHINES. DO NOT EDIT! This block is automatically generated." |
| autogeneratedBlockEnd = "# END GCE MACHINES." |
| |
| skiaSwarmingBotsGCPProject = "skia-swarming-bots" |
| skiaSwarmingBotsInternalGCPProject = "skia-swarming-bots-internal" |
| ) |
| |
| func main() { |
| ctx := context.Background() |
| sshCfgFile, err := getSshCfgFileFromArgs() |
| ifErrThenDie(err) |
| err = updateSshCfg(ctx, sshCfgFile) |
| ifErrThenDie(err) |
| } |
| |
| func getSshCfgFileFromArgs() (string, error) { |
| if len(os.Args) != 2 { |
| return "", skerr.Fmt("Usage: %s <path to ssh.cfg>", os.Args[0]) |
| } |
| return os.Args[1], nil |
| } |
| |
| func updateSshCfg(ctx context.Context, sshCfgFile string) error { |
| // Read the existing ssh.cfg file into an array of lines. |
| var sshCfgFileLines []string |
| err := util.WithReadFile(sshCfgFile, func(f io.Reader) error { |
| scanner := bufio.NewScanner(f) |
| for scanner.Scan() { |
| sshCfgFileLines = append(sshCfgFileLines, scanner.Text()) |
| } |
| sshCfgFileLines = append(sshCfgFileLines, "") // scanner.Scan deletes the last \n. |
| return nil |
| }) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| |
| // Extract out the lines before and after the autogenerated block. |
| linesBefore, linesAfter := parseLinesBeforeAndAfterAutogeneratedBlock(sshCfgFileLines) |
| |
| // Retrieve machine descriptions from GCE (name, external IP, OS). |
| publicMachines, err := describeGCEMachines(ctx, skiaSwarmingBotsGCPProject) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| internalMachines, err := describeGCEMachines(ctx, skiaSwarmingBotsInternalGCPProject) |
| if err != nil { |
| return skerr.Wrap(err) |
| } |
| |
| // Generate host definitions based on the machine descriptions. |
| machines := append(publicMachines, internalMachines...) |
| autogeneratedBlock := generateHostDefinitionsBlock(machines) |
| |
| // Update the ssh.cfg file. |
| allLines := append(linesBefore, autogeneratedBlock) |
| allLines = append(allLines, linesAfter...) |
| newSshCfgFileContents := strings.Join(allLines, "\n") |
| err = util.WithWriteFile(sshCfgFile, func(w io.Writer) error { |
| _, err := w.Write([]byte(newSshCfgFileContents)) |
| return skerr.Wrap(err) |
| }) |
| return skerr.Wrap(err) |
| } |
| |
| // parseLinesBeforeAndAfterAutogeneratedBlock reads an existing ssh.cfg file and returns the lines |
| // before and after the automatically generated block with GCE host definitions. |
| func parseLinesBeforeAndAfterAutogeneratedBlock(sshCfgFileLines []string) ([]string, []string) { |
| var linesBefore []string |
| var linesAfter []string |
| |
| type Location int |
| const ( |
| BeforeBlock Location = iota |
| InBlock |
| AfterBlock |
| ) |
| |
| location := BeforeBlock |
| for _, line := range sshCfgFileLines { |
| switch location { |
| case BeforeBlock: |
| if strings.Contains(line, autogeneratedBlockBegin) { |
| location = InBlock |
| } else { |
| linesBefore = append(linesBefore, line) |
| } |
| case InBlock: |
| if strings.Contains(line, autogeneratedBlockEnd) { |
| location = AfterBlock |
| } |
| case AfterBlock: |
| linesAfter = append(linesAfter, line) |
| default: |
| panic("Unknown location") // Should never happen. |
| } |
| } |
| |
| return linesBefore, linesAfter |
| } |
| |
| type machine struct { |
| name string |
| externalIP string |
| } |
| |
| // describeGCEMachines retrieves the name, external IP and OS of all GCE machines. |
| func describeGCEMachines(ctx context.Context, project string) ([]machine, error) { |
| // This command produces a table with the following format: |
| // |
| // name,nat_ip |
| // skia-e-gce-100,1.2.3.4 |
| // skia-e-gce-101,5.6.7.8 |
| // ... |
| cmd := executil.CommandContext( |
| ctx, |
| "gcloud", |
| "compute", |
| "instances", |
| "list", |
| "--project="+project, |
| "--format=csv(name, networkInterfaces[0].accessConfigs[0].natIP)", |
| "--filter=name~skia-[dei]-*", |
| "--sort-by=name", |
| ) |
| outputBytes, err := cmd.CombinedOutput() |
| if err != nil { |
| return nil, skerr.Fmt("%s\n%s", string(outputBytes), err) |
| } |
| |
| records, err := csv.NewReader(strings.NewReader(string(outputBytes))).ReadAll() |
| if err != nil { |
| return nil, skerr.Wrap(err) |
| } |
| |
| var machines []machine |
| for _, record := range records[1:] { // Skip header record. |
| machine := machine{ |
| name: record[0], |
| externalIP: record[1], |
| } |
| if machine.externalIP == "" { |
| fmt.Printf("Warning: Skipping machine %s because it does not have an external IP address.\n", machine.name) |
| continue |
| } |
| machines = append(machines, machine) |
| } |
| |
| return machines, nil |
| } |
| |
| // generateHostDefinitionsBlock produces the automatically generated block to insert into the |
| // ssh.cfg file. |
| func generateHostDefinitionsBlock(machines []machine) string { |
| hostDefinitionTemplate := "Host %s\n Hostname %s" |
| pieces := []string{autogeneratedBlockBegin} |
| for _, machine := range machines { |
| pieces = append(pieces, fmt.Sprintf(hostDefinitionTemplate, machine.name, machine.externalIP)) |
| } |
| pieces = append(pieces, autogeneratedBlockEnd) |
| |
| return strings.Join(pieces, "\n") |
| } |
| |
| func ifErrThenDie(err error) { |
| if err != nil { |
| fmt.Fprintf(os.Stderr, "%s\n", err) |
| os.Exit(1) |
| } |
| } |