blob: 712aa0a65e02e72f524d4af77e13b47e6cabce27 [file]
// Copyright 2026 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 orchestrate
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"go.fuchsia.dev/fuchsia/tools/lib/ffxutil"
ffx "go.fuchsia.dev/fuchsia/tools/orchestrate/ffx"
utils "go.fuchsia.dev/fuchsia/tools/orchestrate/utils"
)
// FFXStrictClient adapts ffxutil.FFXInstance to implement FFXClient in strict mode.
type FFXStrictClient struct {
// TODO(jcecil): Remove this embedding once strict mode is fully implemented.
*ffx.Ffx // Embed legacy client to fallback on unimplemented methods
// The following fields are read-only after initialization.
ffxInst *ffxutil.FFXInstance
outputsDir string
ffxStrictDir string
sshInfo *ffxutil.SSHInfo
sshDir string
isTemp bool
mu sync.Mutex // protects mutable server lifecycle fields below and target state
serverCtx context.Context
serverCancel context.CancelFunc
serverDone chan struct{}
serverErr error
repoPort int
}
// NewFFXStrictClient creates a new FFXStrictClient.
func NewFFXStrictClient(ctx context.Context, ffxPath, outputsDir, repoName string) (*FFXStrictClient, error) {
if ffxPath == "" {
return nil, fmt.Errorf("ffxPath must not be empty")
}
isTemp := false
if outputsDir == "" {
var err error
outputsDir, err = os.MkdirTemp("", "orchestrate-ffx-strict")
if err != nil {
return nil, fmt.Errorf("failed to create temp dir for ffx strict: %w", err)
}
isTemp = true
}
var success bool
defer func() {
if !success && isTemp {
os.RemoveAll(outputsDir)
}
}()
absOutputsDir, err := filepath.Abs(outputsDir)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path for outputsDir %q: %w", outputsDir, err)
}
ffxStrictDir := filepath.Join(absOutputsDir, "ffx_strict")
sshDir := filepath.Join(ffxStrictDir, "ssh")
if err := os.MkdirAll(sshDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create ssh directory: %w", err)
}
sshPriv := filepath.Join(sshDir, "fuchsia_ed25519")
sshInfo := &ffxutil.SSHInfo{
SshPriv: sshPriv,
}
extraConfigs := ffxutil.ConfigSettings{
Level: "global",
Settings: map[string]any{
"fastboot.flash.timeout_rate": "1",
"fastboot.flash.min_timeout_secs": "600",
"fastboot.usb.disabled": true,
"proactive_log.enabled": false,
"discovery.mdns.enabled": false,
"overnet.cso": "only",
"repository.default": repoName,
"repository.server.enabled": false,
"log.level": "Debug",
"connectivity.direct": true,
},
}
ffxInst, err := ffxutil.NewFFXInstance(
ctx,
ffxPath,
"", // processDir
[]string{}, // env
"", // target
sshInfo,
ffxStrictDir,
ffxutil.UseFFXStrict,
extraConfigs,
)
if err != nil {
return nil, fmt.Errorf("failed to create FFXInstance: %w", err)
}
// NOTE: We initialize the embedded legacy Ffx client with IsolateDir = ffxStrictDir.
// Because Go embedding doesn't dynamically dispatch methods like ApplyEnv, unimplemented
// FFXClient methods falling back to this legacy instance will use its legacy configuration.
// Matching the IsolateDir ensures fallback commands run in the same sandbox as the strict instance.
ffxOpt := &ffx.Option{
ExePath: ffxPath,
LogDir: outputsDir,
IsolateDir: ffxStrictDir,
}
legacyFfx, err := ffx.New(ctx, ffxOpt)
if err != nil {
return nil, fmt.Errorf("failed to create legacy FFX: %w", err)
}
wd, err := os.Getwd()
if err != nil {
return nil, fmt.Errorf("failed to get working directory: %w", err)
}
sshBinDir := filepath.Join(wd, "openssh-portable", "bin")
success = true
return &FFXStrictClient{
Ffx: legacyFfx,
ffxInst: ffxInst,
outputsDir: absOutputsDir,
ffxStrictDir: ffxStrictDir,
sshInfo: sshInfo,
sshDir: sshBinDir,
isTemp: isTemp,
}, nil
}
func (c *FFXStrictClient) Close() error {
c.mu.Lock()
cancel := c.serverCancel
doneChan := c.serverDone
c.mu.Unlock()
if cancel != nil {
cancel()
if doneChan != nil {
<-doneChan
}
c.mu.Lock()
c.serverCancel = nil
c.serverCtx = nil
c.serverDone = nil
c.serverErr = nil
c.repoPort = 0
c.mu.Unlock()
}
// DO NOT call c.Ffx.Close() because it unconditionally deletes its IsolateDir.
// Since we set IsolateDir to ffxStrictDir, calling c.Ffx.Close() would wipe
// the entire ffx_strict sandbox, preventing log collection by Bazel.
err := c.ffxInst.Stop()
if c.isTemp {
if rmErr := os.RemoveAll(c.outputsDir); rmErr != nil {
if err != nil {
return fmt.Errorf("removing temp dir %q: %w (stop err: %v)", c.outputsDir, rmErr, err)
}
return fmt.Errorf("removing temp dir %q: %w", c.outputsDir, rmErr)
}
}
return err
}
func (c *FFXStrictClient) SetDefaultTarget(target *string) {
c.mu.Lock()
defer c.mu.Unlock()
c.Ffx.SetDefaultTarget(target)
if target != nil {
c.ffxInst.SetTarget(*target)
} else {
c.ffxInst.SetTarget("")
}
}
func (c *FFXStrictClient) Flash(ctx context.Context, fastbootSerial, productDir, pubKeyPath string) error {
if err := c.ffxInst.Flash(ctx, fastbootSerial, pubKeyPath, productDir, false); err != nil {
return fmt.Errorf("ffx flash failed: %w", err)
}
return nil
}
func (c *FFXStrictClient) EmuStart(ctx context.Context, productDir, name string) error {
args := []string{
"emu", "start", productDir,
"--net", "user",
"--headless",
"--startup-timeout", "300",
"--name", name,
}
if err := c.ffxInst.Run(ctx, args...); err != nil {
return fmt.Errorf("emu start failed: %w", err)
}
return nil
}
func (c *FFXStrictClient) EmuStop(ctx context.Context) error {
if err := c.ffxInst.Run(ctx, "emu", "stop", "--all"); err != nil {
return fmt.Errorf("emu stop failed: %w", err)
}
return nil
}
func (c *FFXStrictClient) RepositoryCreate(ctx context.Context, repoDir string) error {
if err := c.ffxInst.Run(ctx, "repository", "create", repoDir); err != nil {
return fmt.Errorf("repository create failed: %w", err)
}
return nil
}
func (c *FFXStrictClient) RepositoryPublish(ctx context.Context, repoDir string, productDir string, packageArchives []string) error {
if err := c.ffxInst.Run(ctx, "repository", "publish", repoDir, "--product-bundle", productDir); err != nil {
return fmt.Errorf("repository publish (product-bundle) failed: %w", err)
}
if len(packageArchives) > 0 {
args := []string{"repository", "publish", repoDir}
for _, far := range packageArchives {
args = append(args, "--package-archive", far)
}
if err := c.ffxInst.Run(ctx, args...); err != nil {
return fmt.Errorf("repository publish (package-archives) failed: %w", err)
}
}
return nil
}
func parsePort(addrStr string) (int, error) {
_, portStr, err := net.SplitHostPort(addrStr)
if err != nil {
return 0, err
}
port, err := strconv.Atoi(portStr)
if err != nil {
return 0, err
}
if port <= 0 || port > 65535 {
return 0, fmt.Errorf("port %d out of range (must be > 0 and <= 65535)", port)
}
return port, nil
}
func (c *FFXStrictClient) RepositoryServerStart(ctx context.Context, repoName, repoDir, address string) error {
host, portStr, err := net.SplitHostPort(address)
if err != nil {
return fmt.Errorf("invalid address %q: %w", address, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return fmt.Errorf("invalid port in address %q: %w", address, err)
}
if port < 0 || port > 65535 {
return fmt.Errorf("port %d out of range (must be >= 0 and <= 65535)", port)
}
if host == "" {
host = "::"
}
f, err := os.CreateTemp(c.ffxStrictDir, "repository_port_*.txt")
if err != nil {
return fmt.Errorf("failed to create temp port file: %w", err)
}
portPath := f.Name()
f.Close()
defer os.Remove(portPath)
c.mu.Lock()
if c.serverCancel != nil {
c.mu.Unlock()
return fmt.Errorf("repository server is already running or starting")
}
if port != 0 {
c.repoPort = port
}
serverCtx, serverCancel := context.WithCancel(context.Background())
c.serverCtx = serverCtx
c.serverCancel = serverCancel
c.serverDone = make(chan struct{})
c.serverErr = nil
serverDone := c.serverDone
go func() {
trustedRoot := filepath.Join(repoDir, "repository", "9.root.json")
args := []string{"repository", "server", "start", "--foreground", "--address", net.JoinHostPort(host, portStr),
"--repository", repoName, "--repo-path", repoDir,
"--trusted-root", trustedRoot,
"--alias", "fuchsia.com", "--alias", "chromium.org",
"--no-device",
"--refresh-metadata",
"--port-path", portPath,
}
cmd := c.ffxInst.Command(args...)
err := c.ffxInst.RunCommand(serverCtx, cmd)
c.mu.Lock()
if c.serverCtx == serverCtx {
if !errors.Is(serverCtx.Err(), context.Canceled) {
if err != nil {
c.serverErr = fmt.Errorf("repository server exited: %w", err)
} else {
c.serverErr = fmt.Errorf("repository server exited unexpectedly")
}
}
serverCancel()
c.serverCancel = nil
c.serverCtx = nil
c.serverDone = nil
c.repoPort = 0
}
c.mu.Unlock()
close(serverDone)
}()
c.mu.Unlock()
started := false
defer func() {
if !started {
// Clean up leaked background process and state
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
c.RepositoryServerStop(cleanupCtx, repoName)
}
}()
// Poll for server startup
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
timer := time.NewTimer(5 * time.Second)
defer timer.Stop()
for {
select {
case <-timer.C:
return fmt.Errorf("timed out waiting for repository server to start")
case <-serverDone:
c.mu.Lock()
err := c.serverErr
c.mu.Unlock()
if err != nil {
return err
}
return fmt.Errorf("repository server exited unexpectedly during startup")
case <-ticker.C:
b, err := os.ReadFile(portPath)
if err == nil && len(b) > 0 {
parsedPort, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err == nil && parsedPort > 0 {
if port != 0 && parsedPort != port {
log.Printf("repository server port %d does not yet match expected %d, waiting...", parsedPort, port)
continue
}
c.mu.Lock()
c.repoPort = parsedPort
c.mu.Unlock()
running, err := c.IsPackageServerRunning(ctx, repoName)
if err != nil {
log.Printf("failed to check repository server status: %v", err)
continue
}
if running {
started = true
return nil
}
}
}
case <-ctx.Done():
return ctx.Err()
}
}
}
// RepositoryServerStop stops the repository server.
// repoName is unused in strict mode because FFXStrictClient only manages a single
// foreground repository server, but it is kept to satisfy the FFXClient interface.
func (c *FFXStrictClient) RepositoryServerStop(ctx context.Context, repoName string) error {
c.mu.Lock()
cancel := c.serverCancel
doneChan := c.serverDone
c.mu.Unlock()
if cancel == nil {
return nil
}
cancel()
if doneChan != nil {
select {
case <-doneChan:
case <-ctx.Done():
return fmt.Errorf("timed out waiting for repository server to stop: %w", ctx.Err())
}
}
return nil
}
func (c *FFXStrictClient) RepositoryServerList(ctx context.Context) (string, error) {
out, err := c.ffxInst.RunAndGetOutput(ctx, "repository", "server", "list")
if err != nil {
return "", fmt.Errorf("repository server list failed: %w", err)
}
return out, nil
}
func (c *FFXStrictClient) IsPackageServerRunning(ctx context.Context, repoName string) (bool, error) {
c.mu.Lock()
err := c.serverErr
c.mu.Unlock()
if err != nil {
return false, err
}
stdout, err := c.ffxInst.RunAndGetOutput(ctx, "--machine", "json", "repository", "server", "list")
if err != nil {
return false, fmt.Errorf("failed to list repository servers: %w", err)
}
var result struct {
Ok struct {
Data []struct {
Name string `json:"name"`
Address string `json:"address"`
} `json:"data"`
} `json:"ok"`
UserError struct {
Message string `json:"message"`
} `json:"user_error"`
UnexpectedError struct {
Message string `json:"message"`
} `json:"unexpected_error"`
}
if err := json.Unmarshal([]byte(stdout), &result); err != nil {
return false, fmt.Errorf("failed to unmarshal repository server list output %q: %w", stdout, err)
}
if result.UserError.Message != "" {
return false, fmt.Errorf("repository server list user error: %s", result.UserError.Message)
}
if result.UnexpectedError.Message != "" {
return false, fmt.Errorf("repository server list unexpected error: %s", result.UnexpectedError.Message)
}
c.mu.Lock()
expectedPort := c.repoPort
c.mu.Unlock()
repoNamePrefix := fmt.Sprintf("%s.", repoName)
for _, s := range result.Ok.Data {
if s.Name == repoName || strings.HasPrefix(s.Name, repoNamePrefix) {
port, err := parsePort(s.Address)
if err != nil {
return false, fmt.Errorf("failed to parse port from server address %q: %w", s.Address, err)
}
if expectedPort != 0 && port != expectedPort {
continue
}
c.mu.Lock()
c.repoPort = port
c.mu.Unlock()
return true, nil
}
}
return false, nil
}
func (c *FFXStrictClient) TargetAdd(ctx context.Context, addr string) error {
if err := c.ffxInst.Run(ctx, "target", "add", addr, "--nowait"); err != nil {
return fmt.Errorf("target add failed: %w", err)
}
return nil
}
func (c *FFXStrictClient) TargetList(ctx context.Context) (string, error) {
out, err := c.ffxInst.RunAndGetOutput(ctx, "target", "list")
if err != nil {
return "", fmt.Errorf("target list failed: %w", err)
}
return out, nil
}
func (c *FFXStrictClient) TargetWait(ctx context.Context) error {
c.mu.Lock()
target := c.ffxInst.GetTarget()
c.mu.Unlock()
// FFXWithTarget returns a shallow copy of FFXInstance with the target set.
// We use it here to snapshot the target and ensure thread-safety.
inst := ffxutil.FFXWithTarget(c.ffxInst, target)
if err := inst.TargetWait(ctx); err != nil {
return fmt.Errorf("target wait failed: %w", err)
}
return nil
}
func (c *FFXStrictClient) TargetShow(ctx context.Context) (string, error) {
c.mu.Lock()
target := c.ffxInst.GetTarget()
c.mu.Unlock()
if target == "" {
return "", fmt.Errorf("no target is set")
}
// FFXWithTarget returns a shallow copy of FFXInstance with the target set.
// We use it here to snapshot the target and ensure thread-safety.
inst := ffxutil.FFXWithTarget(c.ffxInst, target)
out, err := inst.RunAndGetOutput(ctx, "--target", target, "target", "show")
if err != nil {
return "", fmt.Errorf("target show failed: %w", err)
}
return out, nil
}
func (c *FFXStrictClient) TargetSnapshot(ctx context.Context, dir string) error {
c.mu.Lock()
target := c.ffxInst.GetTarget()
c.mu.Unlock()
// FFXWithTarget returns a shallow copy of FFXInstance with the target set.
// We use it here to snapshot the target and ensure thread-safety.
inst := ffxutil.FFXWithTarget(c.ffxInst, target)
if err := inst.Snapshot(ctx, dir, ""); err != nil {
return fmt.Errorf("target snapshot failed: %w", err)
}
return nil
}
var xdgEnvVars = []string{"HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME"}
func (c *FFXStrictClient) ApplyEnv(env []string) ([]string, error) {
c.mu.Lock()
target := c.ffxInst.GetTarget()
c.mu.Unlock()
// Make a copy to avoid modifying the caller's slice.
newEnv := make([]string, 0, len(env)+len(xdgEnvVars)+4)
// Filter out variables we are about to override to prevent duplicates.
for _, val := range env {
if strings.HasPrefix(val, "FUCHSIA_DEVICE_ADDR=") {
continue
}
if target != "" && strings.HasPrefix(val, "FUCHSIA_NODENAME=") {
continue
}
if isXdgVar(val) {
continue
}
if strings.HasPrefix(val, "FFX_ISOLATE_DIR=") || strings.HasPrefix(val, "FUCHSIA_ANALYTICS_DISABLED=") {
continue
}
newEnv = append(newEnv, val)
}
// Apply legacy isolation variables
newEnv = append(newEnv, "FFX_ISOLATE_DIR="+c.ffxStrictDir, "FUCHSIA_ANALYTICS_DISABLED=1")
// Apply isolation home directory overrides.
for _, xdgVar := range xdgEnvVars {
newEnv = append(newEnv, xdgVar+"="+c.ffxStrictDir)
}
// Add openssh-portable to PATH.
newEnv = utils.PrependPath(newEnv, c.sshDir)
if target != "" {
newEnv = append(newEnv, "FUCHSIA_NODENAME="+target)
}
return newEnv, nil
}
func isXdgVar(val string) bool {
for _, xdgVar := range xdgEnvVars {
if strings.HasPrefix(val, xdgVar+"=") {
return true
}
}
return false
}
// SetupFfx is a no-op for strict mode since ffxutil.FFXInstance handles repository configuration internally.
func (c *FFXStrictClient) SetupFfx(ctx context.Context, repoName string) error {
return nil
}