golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/bootstrapswarm/bootstrapswarm.go (about) 1 // Copyright 2023 The Go Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style 3 // license that can be found in the LICENSE file. 4 5 // bootstapswarm will bootstrap the swarming bot depending 6 // on the environment that it is run on. 7 // 8 // On GCE: bootstrapswarm will retrieve authentication credentials 9 // from the GCE metadata service and use those credentials to download 10 // the swarming bot. It will then start the swarming bot in a directory 11 // within the user's home directory. 12 // 13 // Requirements: 14 // - Python3 installed and in the calling user's PATH. 15 // 16 // Not on GCE: bootstrapswarm will read the token file and retrieve the 17 // the luci machine token. It will use that token to authenticate and 18 // download the swarming bot. It will then start the swarming bot in a 19 // directory within the user's home directory. 20 // 21 // Requirements: 22 // - Python3 installed and in the calling user's PATH. 23 // - luci_machine_tokend running as root in a cron job. 24 // See https://chromium.googlesource.com/infra/luci/luci-go/+/main/tokenserver. 25 // Further instructions can be found at https://go.dev/wiki/DashboardBuilders. 26 // The default locations for the token files should be used if possible: 27 // Most OS: /var/lib/luci_machine_tokend/token.json 28 // Windows: C:\luci_machine_tokend\token.json 29 // A custom default location can be set via the environment variable LUCI_MACHINE_TOKEN. 30 // - bootstrapswarm should not be run as a privileged user. 31 package main 32 33 import ( 34 "context" 35 "encoding/json" 36 "flag" 37 "fmt" 38 "io" 39 "log" 40 "net/http" 41 "os" 42 "os/exec" 43 "path/filepath" 44 "runtime" 45 "strings" 46 47 "cloud.google.com/go/compute/metadata" 48 ) 49 50 var ( 51 hostname = flag.String("hostname", os.Getenv("HOSTNAME"), "Hostname of machine to bootstrap") 52 swarming = flag.String("swarming", "chromium-swarm.appspot.com", "Swarming server to connect to") 53 ) 54 55 func main() { 56 flag.Usage = func() { 57 fmt.Fprintln(os.Stderr, "Usage: bootstrapswarm") 58 flag.PrintDefaults() 59 } 60 flag.Parse() 61 if *hostname == "" { 62 flag.Usage() 63 os.Exit(2) 64 } 65 ctx := context.Background() 66 if err := bootstrap(ctx, *hostname); err != nil { 67 log.Fatal(err) 68 } 69 } 70 71 var httpClient = http.DefaultClient 72 73 func bootstrap(ctx context.Context, hostname string) error { 74 httpHeaders := map[string]string{} 75 if metadata.OnGCE() { 76 log.Println("Bootstrapping the swarming bot with GCE authentication") 77 log.Println("retrieving the GCE VM token") 78 token, err := retrieveGCEVMToken(ctx) 79 if err != nil { 80 return fmt.Errorf("unable to retrieve GCE Machine Token: %w", err) 81 } 82 httpHeaders["X-Luci-Gce-Vm-Token"] = token 83 84 // Override the hostname flag with the GCE hostname. This is a hard 85 // requirement for LUCI, so there's no point in trying anything else. 86 fullHost, err := metadata.Hostname() 87 if err != nil { 88 return fmt.Errorf("retrieving hostname: %w", err) 89 } 90 hostname = strings.Split(fullHost, ".")[0] 91 } else { 92 log.Println("Bootstrapping the swarming bot with certificate authentication") 93 tokenPath, desc := tokenFile() 94 log.Printf("retrieving the luci-machine-token from the token file %s (%s)\n", tokenPath, desc) 95 tokBytes, err := os.ReadFile(tokenPath) 96 if err != nil { 97 return fmt.Errorf("unable to read file %q: %w", tokenPath, err) 98 } 99 type token struct { 100 LuciMachineToken string `json:"luci_machine_token"` 101 } 102 var tok token 103 if err := json.Unmarshal(tokBytes, &tok); err != nil { 104 return fmt.Errorf("unable to unmarshal token %s: %w", tokenPath, err) 105 } 106 if tok.LuciMachineToken == "" { 107 return fmt.Errorf("unable to retrieve machine token from token file %s", tokenPath) 108 } 109 httpHeaders["X-Luci-Machine-Token"] = tok.LuciMachineToken 110 } 111 httpHeaders["X-Luci-Swarming-Bot-ID"] = hostname 112 log.Println("Downloading the swarming bot") 113 req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+*swarming+"/bot_code", nil) 114 if err != nil { 115 return fmt.Errorf("http.NewRequest: %w", err) 116 } 117 for k, v := range httpHeaders { 118 req.Header.Set(k, v) 119 } 120 resp, err := httpClient.Do(req) 121 if err != nil { 122 return fmt.Errorf("client.Do: %w", err) 123 } 124 defer resp.Body.Close() 125 if resp.StatusCode != 200 { 126 return fmt.Errorf("status code %d", resp.StatusCode) 127 } 128 botBytes, err := io.ReadAll(resp.Body) 129 if err != nil { 130 return fmt.Errorf("io.ReadAll: %w", err) 131 } 132 botPath, err := writeToWorkDirectory(botBytes, "swarming_bot.zip") 133 if err != nil { 134 return fmt.Errorf("unable to save swarming bot to disk: %w", err) 135 } 136 log.Printf("Starting the swarming bot %s", botPath) 137 cmd := exec.CommandContext(ctx, "python3", botPath, "start_bot") 138 // swarming client checks the SWARMING_BOT_ID environment variable for hostname overrides. 139 cmd.Env = append(os.Environ(), fmt.Sprintf("SWARMING_BOT_ID=%s", hostname)) 140 cmd.Stdout = os.Stdout 141 cmd.Stderr = os.Stderr 142 if err := cmd.Run(); err != nil { 143 return fmt.Errorf("command execution %s: %s", cmd, err) 144 } 145 return nil 146 } 147 148 // writeToWorkDirectory writes a file to the swarming working directory and returns the path 149 // to where the file was written. 150 func writeToWorkDirectory(b []byte, filename string) (string, error) { 151 homeDir, err := os.UserHomeDir() 152 if err != nil { 153 return "", fmt.Errorf("os.UserHomeDir: %w", err) 154 } 155 workDir := filepath.Join(homeDir, ".swarming") 156 if err := os.Mkdir(workDir, 0755); err != nil && !os.IsExist(err) { 157 return "", fmt.Errorf("os.Mkdir(%s): %w", workDir, err) 158 } 159 path := filepath.Join(workDir, filename) 160 if err = os.WriteFile(path, b, 0644); err != nil { 161 return "", fmt.Errorf("os.WriteFile(%s): %w", path, err) 162 } 163 return path, nil 164 } 165 166 // retrieveGCEVMToken retrieves a GCE VM token from the GCP metadata service. 167 func retrieveGCEVMToken(ctx context.Context) (string, error) { 168 url := "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://" + *swarming + "&format=full" 169 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 170 if err != nil { 171 return "", fmt.Errorf("http.NewRequest: %w", err) 172 } 173 req.Header.Set("Metadata-Flavor", "Google") 174 resp, err := httpClient.Do(req) 175 if err != nil { 176 return "", fmt.Errorf("client.Do: %w", err) 177 } 178 defer resp.Body.Close() 179 if resp.StatusCode != 200 { 180 return "", fmt.Errorf("status code %d", resp.StatusCode) 181 } 182 b, err := io.ReadAll(resp.Body) 183 if err != nil { 184 return "", fmt.Errorf("io.ReadAll: %w", err) 185 } 186 return string(b), nil 187 } 188 189 // tokenFile reports the path of the LUCI machine token file (used when not on GCE), 190 // and a description of where that value came from. 191 func tokenFile() (path, desc string) { 192 if v := os.Getenv("LUCI_MACHINE_TOKEN"); v != "" { 193 return v, "via LUCI_MACHINE_TOKEN env var" 194 } 195 if runtime.GOOS == "windows" { 196 return `C:\luci_machine_tokend\token.json`, "default path for GOOS == windows" 197 } 198 return "/var/lib/luci_machine_tokend/token.json", "default path for GOOS != windows" 199 }