| // Copyright 2017 The Fuchsia 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 ( |
| "archive/tar" |
| "bytes" |
| "context" |
| "encoding/json" |
| "flag" |
| "fmt" |
| "io" |
| "io/ioutil" |
| "log" |
| "net" |
| "os" |
| "os/signal" |
| "path" |
| "path/filepath" |
| "strings" |
| "syscall" |
| "time" |
| |
| "fuchsia.googlesource.com/infra/infra/netboot" |
| "fuchsia.googlesource.com/infra/infra/pdu" |
| "fuchsia.googlesource.com/infra/infra/retry" |
| "fuchsia.googlesource.com/infra/infra/tftp" |
| ) |
| |
| const ( |
| netbootFilenamePrefix = "<<netboot>>" |
| kernelFilename = netbootFilenamePrefix + "kernel.bin" |
| ramdiskFilename = netbootFilenamePrefix + "ramdisk.bin" |
| cmdlineFilename = netbootFilenamePrefix + "cmdline" |
| |
| imageFilenamePrefix = "<<image>>" |
| efiFilename = imageFilenamePrefix + "efi.img" |
| kerncFilename = imageFilenamePrefix + "kernc.img" |
| fvmFilename = imageFilenamePrefix + "sparse.fvm" |
| |
| summaryFilename = "summary.json" |
| ) |
| |
| type stringsFlag []string |
| |
| func (s *stringsFlag) Set(val string) error { |
| *s = append(*s, val) |
| return nil |
| } |
| |
| func (s *stringsFlag) String() string { |
| if s == nil { |
| return "" |
| } |
| return strings.Join([]string(*s), ", ") |
| } |
| |
| var ( |
| config string // config file path |
| kernelImage string // path to kernel image |
| ramdiskImage string // path to ramdisk image |
| efiImage string // path to efi image |
| kerncImage string // path to kernc image |
| fvmImages stringsFlag // list of paths to fvm images |
| testdir string // path to test directory |
| cmdline string // path to a file containing kernel cmdline args. |
| outfile string // path to output file |
| fileName string // name of the file in the test directory |
| filePollInterval time.Duration // poll interval for file on device |
| ) |
| |
| func init() { |
| flag.StringVar(&config, "config", "/etc/botanist/config.json", "config file path") |
| flag.StringVar(&kernelImage, "kernel", "", "path to kernel image") |
| flag.StringVar(&ramdiskImage, "ramdisk", "", "path to ramdisk image") |
| flag.StringVar(&efiImage, "efi", "", "path to efi image to be paved") |
| flag.StringVar(&kerncImage, "kernc", "", "path to kernc image to be paved") |
| flag.Var(&fvmImages, "fvm", "path to a sparse fvm image to be paved (may be specified up to 4 times)") |
| flag.StringVar(&cmdline, "cmdline", "", "path to a file containing command line arguments") |
| flag.StringVar(&testdir, "test", "/test", "path to test directory") |
| flag.StringVar(&outfile, "out", "output.tar", "path to output file") |
| flag.StringVar(&fileName, "file-name", summaryFilename, "name of the file in the test directory") |
| flag.DurationVar(&filePollInterval, "file-poll-interval", 1*time.Minute, "time between checking for summary.json on the target") |
| } |
| |
| // Files is an ordered map for files which will be TFTP'd over to zedboot. |
| type Files struct { |
| remotes []string |
| locals [][]string |
| } |
| |
| // Set adds a configuration of what files to send for to the remote name. |
| func (f *Files) Set(remote string, locals ...string) { |
| f.remotes = append(f.remotes, remote) |
| f.locals = append(f.locals, locals) |
| } |
| |
| // Foreach calls a callback for each file to send in a filesMap. |
| func (f *Files) Foreach(c func(remote, local string) error) error { |
| for i := 0; i < len(f.remotes); i++ { |
| for j := 0; j < len(f.locals[i]); j++ { |
| if err := c(f.remotes[i], f.locals[i][j]); err != nil { |
| return err |
| } |
| } |
| } |
| return nil |
| } |
| |
| // Config contains the application configuration. |
| type Config struct { |
| // Nodname is the hostname of the hardware device we want to boot on. |
| Nodename string `json:"nodename"` |
| |
| // PDU is the configuration for the PDU attached to the hardware device. |
| PDU *pdu.Config `json:"pdu,omitempty"` |
| } |
| |
| // Summary is the runtest summary output file format. |
| type Summary struct { |
| Tests []struct { |
| Name string `json:"name"` |
| OutputFile string `json:"output_file"` |
| Result string `json:"result"` |
| DataSinks map[string][]struct { |
| Name string `json:"name"` |
| File string `json:"file"` |
| } `json:"data_sinks,omitempty"` |
| } `json:"tests"` |
| Outputs map[string]string `json:"outputs,omitempty"` |
| } |
| |
| func writeFileToTar(client *tftp.Client, tftpAddr *net.UDPAddr, tw *tar.Writer, testDir string, outputFile string) error { |
| writer, err := client.Receive(tftpAddr, path.Join(testDir, outputFile)) |
| if err != nil { |
| return fmt.Errorf("failed to receive file %s: %v\n", outputFile, err) |
| } |
| hdr := &tar.Header{ |
| Name: outputFile, |
| Size: writer.(tftp.Session).Size(), |
| Mode: 0666, |
| } |
| if err := tw.WriteHeader(hdr); err != nil { |
| return fmt.Errorf("failed to write file header: %v\n", err) |
| } |
| if _, err := writer.WriteTo(tw); err != nil { |
| return fmt.Errorf("failed to write file content: %v\n", err) |
| } |
| |
| return nil |
| } |
| |
| func runTests(ctx context.Context, config *Config, files *Files, args []string) error { |
| // Find the node address UDP address. |
| n := netboot.NewClient(time.Second) |
| |
| var addr *net.UDPAddr |
| var err error |
| // We need to retry here because botanist might try to discover before |
| // zedboot is fully ready, so the packet that's sent out doesn't result |
| // in any reply. We don't need to wait between calls because Discover |
| // already has a 1 minute timeout for reading a UDP packet from zedboot. |
| err = retry.Retry(ctx, retry.WithMaxRetries(retry.NewConstantBackoff(time.Second), 60), func() error { |
| addr, err = n.Discover(config.Nodename, false) |
| return err |
| }) |
| if err != nil { |
| return fmt.Errorf("cannot find node \"%s\": %v\n", config.Nodename, err) |
| } |
| |
| // Transfer kernel, ramdisk, and command line args onto the node. |
| client := tftp.NewClient() |
| tftpAddr := &net.UDPAddr{ |
| IP: addr.IP, |
| Port: tftp.ClientPort, |
| Zone: addr.Zone, |
| } |
| |
| if err := transferFiles(ctx, client, tftpAddr, files, args); err != nil { |
| return fmt.Errorf("cannot transfer files: %v\n", err) |
| } |
| |
| // Set up log listener and dump kernel output to stdout. |
| l, err := netboot.NewLogListener(config.Nodename) |
| if err != nil { |
| return fmt.Errorf("cannot listen: %v\n", err) |
| } |
| go func() { |
| defer l.Close() |
| log.Printf("starting log listener\n") |
| for { |
| data, err := l.Listen() |
| if err != nil { |
| continue |
| } |
| fmt.Print(data) |
| select { |
| case <-ctx.Done(): |
| return |
| default: |
| } |
| } |
| }() |
| |
| log.Printf("sending boot command\n") |
| |
| // Boot Fuchsia. |
| if err := n.Boot(addr); err != nil { |
| return fmt.Errorf("cannot boot: %v\n", err) |
| } |
| |
| log.Printf("waiting for \"%s\"\n", fileName) |
| |
| // Poll for summary.json; this relies on runtest being executed using |
| // autorun and it eventually producing the summary.json file. |
| var buffer bytes.Buffer |
| var writer io.WriterTo |
| err = retry.Retry(ctx, retry.NewConstantBackoff(filePollInterval), func() error { |
| writer, err = client.Receive(tftpAddr, path.Join(testdir, fileName)) |
| return err |
| }) |
| if err != nil { |
| return fmt.Errorf("timed out waiting for tests to complete: %v", err) |
| } |
| |
| log.Printf("reading \"%s\"\n", fileName) |
| |
| if _, err := writer.WriteTo(&buffer); err != nil { |
| return fmt.Errorf("failed to receive summary file: %v\n", err) |
| } |
| |
| // Parse and save the summary.json file. |
| var result Summary |
| if err := json.Unmarshal(buffer.Bytes(), &result); err != nil { |
| return fmt.Errorf("cannot unmarshall test results: %v\n", err) |
| } |
| |
| file, err := os.OpenFile(outfile, os.O_WRONLY|os.O_CREATE, 0666) |
| if err != nil { |
| return fmt.Errorf("failed to create file %s: %v\n", outfile, err) |
| } |
| tw := tar.NewWriter(file) |
| defer tw.Close() |
| |
| hdr := &tar.Header{ |
| Name: summaryFilename, |
| Size: int64(buffer.Len()), |
| Mode: 0666, |
| } |
| if err := tw.WriteHeader(hdr); err != nil { |
| return fmt.Errorf("failed to write summary header: %v\n", err) |
| } |
| if _, err := tw.Write(buffer.Bytes()); err != nil { |
| return fmt.Errorf("failed to write summary content: %v\n", err) |
| log.Fatal(err) |
| } |
| |
| log.Printf("copying test output\n") |
| |
| // Copy test output from the node. |
| for _, path := range result.Outputs { |
| if err = writeFileToTar(client, tftpAddr, tw, testdir, path); err != nil { |
| return err |
| } |
| } |
| for _, test := range result.Tests { |
| if err = writeFileToTar(client, tftpAddr, tw, testdir, test.OutputFile); err != nil { |
| return err |
| } |
| // Copy data sinks if any are present. |
| for _, sinks := range test.DataSinks { |
| for _, sink := range sinks { |
| if err = writeFileToTar(client, tftpAddr, tw, testdir, sink.File); err != nil { |
| return err |
| } |
| } |
| } |
| } |
| |
| return nil |
| } |
| |
| // transferFiles sends kernel, optionally ramdisk, and any command line args to |
| // node. |
| func transferFiles(ctx context.Context, client *tftp.Client, addr *net.UDPAddr, files *Files, cmdline []string) error { |
| err := files.Foreach(func(remote, local string) error { |
| file, err := os.Open(local) |
| if err != nil { |
| return fmt.Errorf("cannot open %s: %v\n", local, err) |
| } |
| defer file.Close() |
| |
| fi, err := file.Stat() |
| if err != nil { |
| return fmt.Errorf("cannot stat %s: %v\n", local, err) |
| } |
| // We need to retry because in the event of paving, TFTP might tell us |
| // it's not ready yet. |
| return retry.Retry(ctx, retry.WithMaxRetries(retry.NewConstantBackoff(time.Second), 60), func() error { |
| fmt.Printf("attempting to send %s=%s\n", remote, filepath.Base(local)) |
| reader, err := client.Send(addr, remote, fi.Size()) |
| if err != nil { |
| return fmt.Errorf("failed to send %s: %v\n", remote, err) |
| } |
| if _, err := reader.ReadFrom(file); err != nil { |
| return fmt.Errorf("failed to send %s data: %v\n", local, err) |
| } |
| return nil |
| }) |
| }) |
| if err != nil { |
| return err |
| } |
| if len(cmdline) > 0 { |
| var b bytes.Buffer |
| for _, arg := range cmdline { |
| fmt.Fprintf(&b, "%s\n", arg) |
| } |
| |
| log.Printf("sending cmdline \"%s\"", b.String()) |
| |
| reader, err := client.Send(addr, cmdlineFilename, int64(b.Len())) |
| if err != nil { |
| return fmt.Errorf("failed to send cmdline: %v\n", err) |
| } |
| if _, err := reader.ReadFrom(bytes.NewReader(b.Bytes())); err != nil { |
| return fmt.Errorf("failed to read cmdline data: %v\n", err) |
| } |
| } |
| return nil |
| } |
| |
| func loadConfig(ctx context.Context, path string) (*Config, error) { |
| file, err := os.Open(path) |
| if err != nil { |
| return nil, err |
| } |
| |
| var config Config |
| if err := json.NewDecoder(file).Decode(&config); err != nil { |
| return nil, err |
| } |
| |
| return &config, err |
| } |
| |
| func run(ctx context.Context, config *Config, files *Files, cmdline []string) error { |
| if config.PDU != nil { |
| defer func() { |
| log.Printf("rebooting the node \"%s\"\n", config.Nodename) |
| |
| if err := pdu.RebootDevice(config.PDU); err != nil { |
| log.Fatalf("failed to reboot the device: %v", err) |
| } |
| }() |
| } |
| |
| ctx, cancel := context.WithCancel(ctx) |
| |
| // Handle SIGTERM and make sure we send a reboot to the device. |
| signals := make(chan os.Signal, 1) |
| signal.Notify(signals, syscall.SIGTERM) |
| |
| errs := make(chan error) |
| go func() { |
| errs <- runTests(ctx, config, files, cmdline) |
| }() |
| |
| select { |
| case err := <-errs: |
| return err |
| case <-signals: |
| cancel() |
| } |
| |
| return nil |
| } |
| |
| func mustExist(filename string) { |
| _, err := os.Stat(filename) |
| if err != nil { |
| log.Fatalf("failed to stat %s: %v", filename, err) |
| } |
| } |
| |
| func main() { |
| flag.Parse() |
| |
| ctx := context.Background() |
| |
| config, err := loadConfig(ctx, config) |
| if err != nil { |
| log.Fatal(err) |
| } |
| |
| // Files is effectively an ordered map, so the order in which |
| // its contents are added is the order in which they'll be transferred |
| // later. |
| var files Files |
| if ramdiskImage != "" { |
| mustExist(ramdiskImage) |
| files.Set(ramdiskFilename, ramdiskImage) |
| } |
| if len(fvmImages) != 0 { |
| for _, image := range fvmImages { |
| mustExist(image) |
| } |
| files.Set(fvmFilename, fvmImages...) |
| } |
| if efiImage != "" { |
| mustExist(efiImage) |
| files.Set(efiFilename, efiImage) |
| } |
| if kerncImage != "" { |
| mustExist(kerncImage) |
| files.Set(kerncFilename, kerncImage) |
| } |
| if kernelImage == "" { |
| log.Fatal("-kernel is required") |
| } |
| mustExist(kernelImage) |
| files.Set(kernelFilename, kernelImage) |
| |
| // Aggregate all cmdline flags. |
| cmdlineArgs := flag.Args() |
| if cmdline != "" { |
| b, err := ioutil.ReadFile(cmdline) |
| if err != nil { |
| log.Fatal("failed to read cmdline file: %v", err) |
| } |
| cmdlineArgs = append(cmdlineArgs, strings.Split(string(b), "\n")...) |
| } |
| |
| if err := run(ctx, config, &files, cmdlineArgs); err != nil { |
| log.Fatal(err) |
| } |
| } |