| // Copyright 2024 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 ffx provides wrappers and convenience functions for using the ffx binaries. |
| package orchestrate |
| |
| import ( |
| "context" |
| "encoding/json" |
| "errors" |
| "fmt" |
| "io" |
| "log" |
| "os" |
| "os/exec" |
| "path/filepath" |
| "strings" |
| "time" |
| |
| utils "go.fuchsia.dev/fuchsia/tools/orchestrate/utils" |
| ) |
| |
| // XDG_ENV_VARS are leaky environment variables to override. See ApplyEnv. |
| var XDG_ENV_VARS = [...]string{ |
| "HOME", |
| "XDG_CACHE_HOME", |
| "XDG_CONFIG_HOME", |
| "XDG_DATA_HOME", |
| "XDG_HOME", |
| "XDG_STATE_HOME", |
| } |
| |
| // Ffx defines settings for ffx commands. |
| type Ffx struct { |
| // Dir is the working directory for ffx, and where it writes files. |
| Dir string |
| bin string |
| sslCertPath string |
| } |
| |
| // Option for creating Ffx. |
| type Option struct { |
| // IsolateDir is the directory where all ffx state is stored. This can be |
| // used to allow multiple instances of ffx to share the same configuration. |
| // If the directory contents are empty, a default config will be generated. |
| // If not set, a temporary directory will be created for this instance of |
| // Ffx to use. |
| IsolateDir string |
| |
| // ExePath is the path to the ffx cli tool. |
| ExePath string |
| |
| // SSLCertPath is the path to the SSL certificates that ffx should use. If |
| // not set, the default certificates will be used from the runfiles. |
| // If the runfiles are not available, then the system paths will be searched |
| // for appropriate certificates. |
| SSLCertPath string |
| |
| // LogDir is the directory where ffx logs are stored. |
| // If not set, it defaults to a sub-directory of the IsolateDir. |
| // |
| // Note: This option is only used to write the default ffx config when |
| // initializing an empty isolate directory. Otherwise, the existing config in |
| // IsolateDir is used. |
| LogDir string |
| |
| // PrivateSSH is the list of the pregenerated private ssh key files that ffx |
| // should use to connect to targets. |
| // |
| // Note: This option is only used to write the default ffx config when |
| // initializing an empty isolate directory. Otherwise, the existing config in |
| // IsolateDir is used. |
| PrivateSSH []string |
| |
| // PublicSSH is the list of the pregenerated public ssh key files and |
| // authorized_keys files that ffx should install when flashing targets or |
| // starting up emulator instances. |
| // |
| // Note: This option is only used to write the default ffx config when |
| // initializing an empty isolate directory. Otherwise, the existing config in |
| // IsolateDir is used. |
| PublicSSH []string |
| |
| // EnableCSO enables circuit-switched-overnet in the ffx daemon. |
| // If set to false, the ffx default value is used. |
| // |
| // Note: This option is only used to write the default ffx config when |
| // initializing an empty isolate directory. Otherwise, the existing config in |
| // IsolateDir is used. |
| EnableCSO bool |
| } |
| |
| // New sets up a config and filepaths for local or Forge use. |
| func New(opt *Option) (*Ffx, error) { |
| f := &Ffx{ |
| bin: opt.ExePath, |
| sslCertPath: opt.SSLCertPath, |
| Dir: opt.IsolateDir, |
| } |
| var err error |
| if f.Dir == "" { |
| if f.Dir, err = os.MkdirTemp("", "ffx"); err != nil { |
| return nil, fmt.Errorf("create directory for ffx: %w", err) |
| } |
| } |
| |
| if f.bin == "" { |
| return nil, errors.New("must provide path to ffx executable") |
| } |
| |
| // Setup the default config if it has not been initialized yet. |
| configPath := filepath.Join(f.Dir, ".ffx_user_config.json") |
| if _, err := os.Stat(configPath); os.IsNotExist(err) { |
| if err := f.setupDefaultConfig(configPath, *opt); err != nil { |
| return nil, err |
| } |
| } else if err != nil { |
| return nil, fmt.Errorf("unable to stat config file: %w", err) |
| } |
| return f, nil |
| } |
| |
| func (f *Ffx) setupDefaultConfig(configPath string, opt Option) error { |
| if opt.LogDir == "" { |
| opt.LogDir = filepath.Join(f.Dir, "log") |
| } |
| if err := os.MkdirAll(opt.LogDir, 0755); err != nil { |
| return err |
| } |
| |
| socketPath := filepath.Join(f.Dir, "ascendd") |
| err := writeConfigFile(configPath, opt, socketPath) |
| if err != nil { |
| return err |
| } |
| |
| // Write the config to the isolation dir so that we don't need to pass it with every command. |
| if out, err := f.RunCmdSync("config", "env", "set", configPath); err != nil { |
| return fmt.Errorf("saving ffx config path: %s, %w", out, err) |
| } |
| return nil |
| } |
| |
| // Cmd returns a generic exec.Cmd configured to execute ffx. |
| func (f *Ffx) Cmd(args ...string) *exec.Cmd { |
| cmd := exec.Command(f.bin, args...) |
| cmd.Env = f.ApplyEnv(cmd.Environ()) |
| return cmd |
| } |
| |
| // ApplyEnv adds the environment variables needed for safe execution of ffx. |
| func (f *Ffx) ApplyEnv(env []string) []string { |
| env = append(env, "FFX_ISOLATE_DIR="+f.Dir, "FUCHSIA_ANALYTICS_DISABLED=1") |
| if f.sslCertPath != "" { |
| env = append(env, "SSL_CERT_FILE="+f.sslCertPath) |
| } |
| // Override HOME and other HOME-related environment variables, since ffx and |
| // tests shouldn't assume anything about those. |
| // This prevents ffx from creating and using default ssh keys from the real |
| // home directory. |
| for _, xdg_env_var := range XDG_ENV_VARS { |
| env = append(env, xdg_env_var+"="+f.Dir) |
| } |
| return env |
| } |
| |
| // RunCmdSync starts a command and waits for the command to complete. |
| func (f *Ffx) RunCmdSync(args ...string) (string, error) { |
| cmd := f.Cmd(args...) |
| log.Printf("Running command and streaming output: %+v", cmd.Args) |
| |
| // Pipe stderr to stdout, and then tee to a string builder. |
| var output strings.Builder |
| outputWriter := io.MultiWriter(&output, os.Stdout) |
| cmd.Stdout = outputWriter |
| cmd.Stderr = outputWriter |
| |
| if err := cmd.Run(); err != nil { |
| return "", fmt.Errorf("cmd.Run: %w", err) |
| } |
| |
| return output.String(), nil |
| } |
| |
| // RunCmdAsync starts a command but does NOT wait for the command to complete. |
| func (f *Ffx) RunCmdAsync(args ...string) (*exec.Cmd, error) { |
| cmd := f.Cmd(args...) |
| log.Printf("Running background command: %+v", cmd.Args) |
| if err := cmd.Start(); err != nil { |
| return cmd, fmt.Errorf("start command %s: %w", args, err) |
| } |
| return cmd, nil |
| } |
| |
| // ConfigGet reads from the ffx config and writes to result as structured data. |
| func (f *Ffx) ConfigGet(field string, result any) error { |
| out, err := f.RunCmdSync("config", "get", field) |
| if err != nil { |
| return fmt.Errorf("ffx config failed for %q: %w", field, err) |
| } |
| if err := json.Unmarshal([]byte(out), result); err != nil { |
| return fmt.Errorf("unable to unmarshal config output: %w", err) |
| } |
| return nil |
| } |
| |
| // Close removes all files from the ffx directory. |
| func (f *Ffx) Close() error { |
| return os.RemoveAll(f.Dir) |
| } |
| |
| // GetDefaultTarget fetches the default ffx target. |
| func (f *Ffx) GetDefaultTarget() (string, error) { |
| defaultName, err := f.RunCmdSync("target", "default", "get") |
| if err != nil { |
| return "", fmt.Errorf("run \"target default get\" command. %s. %w", defaultName, err) |
| } |
| // An extra '\n' is added at the end of defaultName. |
| return strings.TrimSpace(defaultName), nil |
| } |
| |
| // WaitForDaemon tries a few times to check that the daemon is up |
| // and returns an error if it fails to respond. |
| func (f *Ffx) WaitForDaemon(ctx context.Context) error { |
| return utils.RunWithRetries(context.Background(), 500*time.Millisecond, 3, func() error { |
| _, err := f.RunCmdSync("daemon", "echo") |
| return err |
| }) |
| } |
| |
| // Flash uses "ffx target flash" to flash a product bundle into a device. |
| // pubKeyPath is optional and ignored if empty. |
| func (f *Ffx) Flash(fastbootSerial, productDir, pubKeyPath string) error { |
| ffxArgs := []string{ |
| "--target", fastbootSerial, |
| "--config", "{\"ffx\": {\"fastboot\": {\"inline_target\": true}}}", |
| "target", "flash", |
| "--product-bundle", productDir} |
| if pubKeyPath != "" { |
| ffxArgs = append(ffxArgs, "--authorized-keys", pubKeyPath) |
| } |
| _, err := f.RunCmdSync(ffxArgs...) |
| return err |
| } |
| |
| func writeConfigFile(configPath string, opt Option, socketPath string) error { |
| overnet := map[string]string{"socket": socketPath} |
| if opt.EnableCSO { |
| overnet["cso"] = "enabled" |
| } |
| ssh := map[string][]string{} |
| if len(opt.PrivateSSH) > 0 { |
| ssh["priv"] = opt.PrivateSSH |
| } |
| if len(opt.PublicSSH) > 0 { |
| ssh["pub"] = opt.PublicSSH |
| } |
| data := map[string]any{ |
| "overnet": overnet, |
| "proxy": map[string]int{ |
| "timeout_secs": 60, |
| }, |
| "ssh": ssh, |
| "log": map[string]any{ |
| "dir": []string{opt.LogDir}, |
| "enabled": []bool{true}, |
| "level": "Debug", |
| }, |
| "test": map[string]any{ |
| "suite_start_timeout_seconds": 600, |
| }, |
| } |
| j, err := json.Marshal(data) |
| if err != nil { |
| return fmt.Errorf("marshal ffx config: %w", err) |
| } |
| if err := os.WriteFile(configPath, j, 0o600); err != nil { |
| return fmt.Errorf("writing ffx config to file: %w", err) |
| } |
| return nil |
| } |