blob: 5ecd21983f84dbe28fd8749f0c81a55beccc150f [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can
// found in the LICENSE file.
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
"cloud.google.com/go/storage"
devicePkg "go.fuchsia.dev/infra/devices"
)
const (
failureExitCode = 1
portEnvVar = "NUC_SERVER_PORT"
// chainloaderExitStr instructs a NUC to exit IPXE and boot from disk
chainloaderExitStr = "#!ipxe\necho Catalyst chain loader\nexit 0\n"
rebootDuration = 1 * time.Minute
zedbootPath = "zedboot.zbi"
)
var (
imagesManifest string
deviceConfigPath string
bootserverPath string
disableRebootServer bool
)
func init() {
flag.StringVar(&imagesManifest, "images", "", "Path to the images manifest json")
flag.StringVar(&deviceConfigPath, "config", "", "Path to the device config file")
flag.StringVar(&bootserverPath, "bootserver", "", "Path to the bootserver binary")
flag.BoolVar(&disableRebootServer, "disable-reboot-server", false, "Disables the NUC soft reboot server")
}
// Runs a subprocess and sets up a handler that propagates SIGTERM on context cancel.
func runSubprocess(ctx context.Context, command []string) int {
if len(command) == 0 {
return 0
}
cmd := exec.Command(command[0], command[1:]...)
// Spin off handler to exit subprocesses cleanly via SIGTERM.
processDone := make(chan bool, 1)
go func() {
select {
case <-processDone:
case <-ctx.Done():
if err := cmd.Process.Signal(syscall.SIGTERM); err != nil {
log.Printf("exited cmd with error %v", err)
}
}
}()
// Ensure that the context still exists before running the subprocess.
if ctx.Err() != nil {
return failureExitCode
}
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Run()
processDone <- true
return cmd.ProcessState.ExitCode()
}
// runNUCServer runs an http server that delivers chainloaders/images to NUCs based on their mac.
// These chainloaders deliver a build's version of zedboot and an exit chainloader (to allow reboot from disk).
func runNUCServer(ctx context.Context, devices []devicePkg.Device, disabled bool) (*http.Server, error) {
// Parse the port from the environment variable.
portStr := os.Getenv(portEnvVar)
if portStr == "" || disabled {
return nil, nil
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, err
}
mux := http.NewServeMux()
for _, device := range devices {
// Add endpoint for exit chainloader. This allows NUCs to reboot from disk.
mux.HandleFunc(fmt.Sprintf("/%s.ipxe", device.Mac()), func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s.ipxe", device.Mac()))
chainloader := strings.NewReader(chainloaderExitStr)
io.Copy(w, chainloader)
})
// Add single-use endpoint to deliver build's version of zedboot.
zedbootFile, err := os.Open(zedbootPath)
if err != nil {
zedbootFile = nil
}
mux.HandleFunc(fmt.Sprintf("/zedboot/%s", device.Mac()), func(w http.ResponseWriter, r *http.Request) {
if zedbootFile == nil {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", device.Mac()))
io.Copy(w, zedbootFile)
zedbootFile.Close()
zedbootFile = nil
})
}
srv := &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: mux,
}
go srv.ListenAndServe()
return srv, nil
}
// prepDevices puts all devices into TaskState.
func prepDevices(ctx context.Context, devices []devicePkg.Device) error {
// Put each device into TaskState.
errorChannel := make(chan error)
for _, device := range devices {
go func(device devicePkg.Device) {
errorChannel <- device.ToTaskState(ctx)
}(device)
}
for i := 0; i < len(devices); i++ {
if err := <-errorChannel; err != nil {
return err
}
}
return nil
}
// hasNUC returns true iff this testbed contains a NUC.
func hasNUC(devices []devicePkg.Device) bool {
for _, device := range devices {
if _, ok := device.(*devicePkg.Nuc); ok {
return true
}
}
return false
}
// downloadZedboot retrieves zedboot.zbi from GCS so that we can use the proper image
// in NUCs.
func downloadZedboot(ctx context.Context, imageURL *url.URL, devices []devicePkg.Device) error {
// Ensure that we have a NUC that needs a local zedboot.zbi.
if !hasNUC(devices) {
return nil
}
// If we are not getting images from GCS, then this is a no-op.
if imageURL.Scheme != "gs" {
return nil
}
// Connect to GCS.
bucket := imageURL.Host
client, err := storage.NewClient(ctx)
if err != nil {
return err
}
bkt := client.Bucket(bucket)
// Construct path to zedboot given images manifest path.
gcsZedbootPath := strings.TrimLeft(
fmt.Sprintf("%s/%s", filepath.Dir(imageURL.Path), zedbootPath), "/",
)
// Get reader to GCS object.
r, err := bkt.Object(gcsZedbootPath).NewReader(ctx)
if err != nil {
return err
}
defer r.Close()
// Open a local zedboot file and download remote data into it.
file, err := os.Create(zedbootPath)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(file, r); err != nil {
return err
}
return nil
}
func execute(ctx context.Context, subcommandArgs []string) (int, error) {
// If this is a QEMU test bench, skip the device setup and just run the subprocess.
if deviceConfigPath == "" {
return runSubprocess(ctx, subcommandArgs), nil
}
// Contains all necessary bootserver flags except device nodename.
bootserverCmdStub := []string{
bootserverPath,
"--images", imagesManifest,
"--mode", "pave-zedboot",
"-n",
}
// Create devicePkg.Devices for each of the devices in the config file
configs, err := devicePkg.LoadDeviceConfigs(deviceConfigPath)
if err != nil {
return failureExitCode, err
}
devices, err := devicePkg.CreateDevices(ctx, configs, bootserverCmdStub)
if err != nil {
return failureExitCode, err
}
// If the image manifest provided is a gcs url and we have a NUC, we need to download
// zedboot. If the image manifest is a file path, then zedboot.zbi should exist in this
// directory.
imageURL, err := url.Parse(imagesManifest)
if err != nil {
return failureExitCode, err
}
if imageURL.Scheme == "gs" {
// This is a no-op if no NUCs exist on this testbed.
if err := downloadZedboot(ctx, imageURL, devices); err != nil {
return failureExitCode, err
}
}
// Set up a NUC server. If the port environment variable is not set, or if
// the disableRebootServer flag is set, this is a no-op.
srv, err := runNUCServer(ctx, devices, disableRebootServer)
if err != nil {
return failureExitCode, err
}
// Clean up after tests
defer func() {
if srv != nil {
if err := srv.Shutdown(ctx); err != nil {
log.Printf("NUC server shutdown failed: %v", err)
}
}
}()
if err := prepDevices(ctx, devices); err != nil {
return failureExitCode, err
}
time.Sleep(rebootDuration)
// Execute the passed in subcommand
return runSubprocess(ctx, subcommandArgs), nil
}
func main() {
// Initialize
flag.Parse()
// Handle SIGTERM
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
signals := make(chan os.Signal)
defer func() {
signal.Stop(signals)
close(signals)
}()
signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT)
go func() {
select {
case <-signals:
cancel()
case <-ctx.Done():
}
}()
// Resolve environment variables within the subcommand's command line, as
// Swarming does not do it automatically, and best to have this done once and
// uniformly across all possible test task commands.
var subcmdArgs []string
for _, arg := range flag.Args() {
subcmdArgs = append(subcmdArgs, os.ExpandEnv(arg))
}
exitCode, err := execute(ctx, subcmdArgs)
if err != nil {
log.Printf("Exit code: %d, Err: %s\n", exitCode, err)
}
os.Exit(exitCode)
}