blob: 609a3f43e3828c8462efaf4c023f09b634a350af [file] [log] [blame]
// Copyright 2023 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.
// bootstapswarm will bootstrap the swarming bot depending
// on the environment that it is run on.
//
// On GCE: bootstrapswarm will retrieve authentication credentials
// from the GCE metadata service and use those credentials to download
// the swarming bot. It will then start the swarming bot in a directory
// within the user's home directory.
//
// Requirements:
// - Python3 installed and in the calling user's PATH.
//
// Not on GCE: bootstrapswarm will read the token file and retrieve the
// the luci machine token. It will use that token to authenticate and
// download the swarming bot. It will then start the swarming bot in a
// directory within the user's home directory.
//
// Requirements:
// - Python3 installed and in the calling user's PATH.
// - luci_machine_tokend running as root in a cron job.
// See https://chromium.googlesource.com/infra/luci/luci-go/+/main/tokenserver.
// Further instructions can be found at https://go.dev/wiki/DashboardBuilders.
// The default locations for the token files should be used if possible:
// Most OS: /var/lib/luci_machine_tokend/token.json
// Windows: C:\luci_machine_tokend\token.json
// A custom default location can be set via the environment variable LUCI_MACHINE_TOKEN.
// - bootstrapswarm should not be run as a privileged user.
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"cloud.google.com/go/compute/metadata"
)
var (
hostname = flag.String("hostname", os.Getenv("HOSTNAME"), "Hostname of machine to bootstrap")
swarming = flag.String("swarming", "chromium-swarm.appspot.com", "Swarming server to connect to")
)
func main() {
flag.Usage = func() {
fmt.Fprintln(os.Stderr, "Usage: bootstrapswarm")
flag.PrintDefaults()
}
flag.Parse()
if *hostname == "" {
flag.Usage()
os.Exit(2)
}
ctx := context.Background()
if err := bootstrap(ctx, *hostname); err != nil {
log.Fatal(err)
}
}
var httpClient = http.DefaultClient
func bootstrap(ctx context.Context, hostname string) error {
httpHeaders := map[string]string{}
if metadata.OnGCE() {
log.Println("Bootstrapping the swarming bot with GCE authentication")
log.Println("retrieving the GCE VM token")
token, err := retrieveGCEVMToken(ctx)
if err != nil {
return fmt.Errorf("unable to retrieve GCE Machine Token: %w", err)
}
httpHeaders["X-Luci-Gce-Vm-Token"] = token
// Override the hostname flag with the GCE hostname. This is a hard
// requirement for LUCI, so there's no point in trying anything else.
fullHost, err := metadata.Hostname()
if err != nil {
return fmt.Errorf("retrieving hostname: %w", err)
}
hostname = strings.Split(fullHost, ".")[0]
} else {
log.Println("Bootstrapping the swarming bot with certificate authentication")
tokenPath, desc := tokenFile()
log.Printf("retrieving the luci-machine-token from the token file %s (%s)\n", tokenPath, desc)
tokBytes, err := os.ReadFile(tokenPath)
if err != nil {
return fmt.Errorf("unable to read file %q: %w", tokenPath, err)
}
type token struct {
LuciMachineToken string `json:"luci_machine_token"`
}
var tok token
if err := json.Unmarshal(tokBytes, &tok); err != nil {
return fmt.Errorf("unable to unmarshal token %s: %w", tokenPath, err)
}
if tok.LuciMachineToken == "" {
return fmt.Errorf("unable to retrieve machine token from token file %s", tokenPath)
}
httpHeaders["X-Luci-Machine-Token"] = tok.LuciMachineToken
}
httpHeaders["X-Luci-Swarming-Bot-ID"] = hostname
log.Println("Downloading the swarming bot")
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+*swarming+"/bot_code", nil)
if err != nil {
return fmt.Errorf("http.NewRequest: %w", err)
}
for k, v := range httpHeaders {
req.Header.Set(k, v)
}
resp, err := httpClient.Do(req)
if err != nil {
return fmt.Errorf("client.Do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("status code %d", resp.StatusCode)
}
botBytes, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("io.ReadAll: %w", err)
}
botPath, err := writeToWorkDirectory(botBytes, "swarming_bot.zip")
if err != nil {
return fmt.Errorf("unable to save swarming bot to disk: %w", err)
}
log.Printf("Starting the swarming bot %s", botPath)
cmd := exec.CommandContext(ctx, "python3", botPath, "start_bot")
// swarming client checks the SWARMING_BOT_ID environment variable for hostname overrides.
cmd.Env = append(os.Environ(), fmt.Sprintf("SWARMING_BOT_ID=%s", hostname))
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("command execution %s: %s", cmd, err)
}
return nil
}
// writeToWorkDirectory writes a file to the swarming working directory and returns the path
// to where the file was written.
func writeToWorkDirectory(b []byte, filename string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("os.UserHomeDir: %w", err)
}
workDir := filepath.Join(homeDir, ".swarming")
if err := os.Mkdir(workDir, 0755); err != nil && !os.IsExist(err) {
return "", fmt.Errorf("os.Mkdir(%s): %w", workDir, err)
}
path := filepath.Join(workDir, filename)
if err = os.WriteFile(path, b, 0644); err != nil {
return "", fmt.Errorf("os.WriteFile(%s): %w", path, err)
}
return path, nil
}
// retrieveGCEVMToken retrieves a GCE VM token from the GCP metadata service.
func retrieveGCEVMToken(ctx context.Context) (string, error) {
url := "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://" + *swarming + "&format=full"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", fmt.Errorf("http.NewRequest: %w", err)
}
req.Header.Set("Metadata-Flavor", "Google")
resp, err := httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("client.Do: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("status code %d", resp.StatusCode)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("io.ReadAll: %w", err)
}
return string(b), nil
}
// tokenFile reports the path of the LUCI machine token file (used when not on GCE),
// and a description of where that value came from.
func tokenFile() (path, desc string) {
if v := os.Getenv("LUCI_MACHINE_TOKEN"); v != "" {
return v, "via LUCI_MACHINE_TOKEN env var"
}
if runtime.GOOS == "windows" {
return `C:\luci_machine_tokend\token.json`, "default path for GOOS == windows"
}
return "/var/lib/luci_machine_tokend/token.json", "default path for GOOS != windows"
}