diff --git a/cmd/main.go b/cmd/main.go index b2e2ffc..905a0c4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -265,10 +265,10 @@ func runRunner() error { } } - // Best-effort: stage the `fduty` CLI onto the bash PATH and log a manual- - // install hint if it is missing. Deliberately non-fatal — the runner must - // start even without fduty (it can still do non-fduty work); see ensureFdutyCLI. - ensureFdutyCLI() + // Side mode: stage + self-update the `fduty` CLI on a background goroutine so + // it is OFF the startup path entirely. Non-blocking and non-fatal — the runner + // starts immediately; fduty resolves a beat later. See StartFdutyProvisioningAsync. + StartFdutyProvisioningAsync() checker := permission.NewChecker(map[string]string{"*": "allow"}) diff --git a/cmd/provision.go b/cmd/provision.go index f984cdc..bcbd260 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -9,6 +9,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "github.com/flashcatcloud/flashduty-runner/environment" @@ -24,25 +25,39 @@ import ( // local testing). var cliInstallURL = "" -// ensureFdutyCLI makes a BEST-EFFORT attempt to put the `fduty` CLI on the bash -// tool's PATH, then reports whether it is usable. It NEVER aborts startup: a -// runner missing fduty can still do non-fduty work, and a missing CLI is far -// easier to diagnose on a running, loudly-logging runner than on one that -// refuses to boot. When fduty is unavailable it logs an actionable manual- -// install hint; agent commands that call `fduty` will 127 until it is resolved. +// fdutyUpdateTimeout bounds the background `fduty update` network step. It is +// generous — the whole ensure+update task is a detached side goroutine, never on +// the startup path, so a slow CDN only delays fduty readiness, never the runner. +// provisionFduty and verifyFdutyOnPath each carry their own timeouts, so no +// overall wrapper is needed. +const fdutyUpdateTimeout = 90 * time.Second + +// StartFdutyProvisioningAsync launches fduty provisioning + self-update on a +// detached goroutine so it is NEVER part of the runner's startup path ("side +// mode"). The runner is usable for non-fduty work immediately; the fast, local +// bundled-copy stage makes `fduty` resolve a beat later, and a newer release is +// pulled in place if one exists. Because a runner self-update ends in a re-exec, +// the next boot's side-task upgrades fduty "alongside" the runner. Every failure +// is swallowed and logged — provisioning must never disturb a running runner. +func StartFdutyProvisioningAsync() { + go ensureFdutyCLI() +} + +// ensureFdutyCLI best-effort stages the `fduty` CLI into the bundled-tools dir, +// self-checks it on the bash PATH, and — when present — keeps it current via the +// CLI's own `fduty update`. It NEVER aborts anything: callers run it off the +// startup path (see StartFdutyProvisioningAsync), and a missing CLI only logs an +// actionable manual-install hint (agent `fduty` calls 127 until resolved). // -// Auto-staging order for getting fduty into the bundled-tools dir (the dir -// environment.BundledToolsDir prepends to every bash PATH — writable by -// construction, see its doc comment): -// 1. already present there (cloud image bakes it; install.sh stages the bundled -// copy) → nothing to do; -// 2. a bundled fduty shipped next to the runner executable → copy it in (no -// network); +// Auto-staging order for the bundled-tools dir (environment.BundledToolsDir +// prepends it to every bash PATH — writable by construction): +// 1. already present (cloud image bakes it; install.sh stages the bundled copy); +// 2. a bundled fduty next to the runner executable → copy it in (no network); // 3. CDN install.sh fallback, when a URL is configured. // // A staging miss is not fatal — fduty may already be reachable elsewhere on the // bash PATH (e.g. /usr/local/bin from a binary-only install). verifyFdutyOnPath -// is the single source of truth for "is fduty usable", and it logs the outcome. +// is the single source of truth for "is fduty usable". func ensureFdutyCLI() { if dir := environment.BundledToolsDir(); dir != "" { if err := provisionFduty(dir, filepath.Join(dir, "fduty")); err != nil { @@ -57,7 +72,66 @@ func ensureFdutyCLI() { "but agent commands that call `fduty` will fail until you install it. To fix: place an "+ "executable `fduty` in the runner's tools dir ($FLASHDUTY_RUNNER_HOME/bin, default ~/.flashduty/bin) "+ "or anywhere on PATH (e.g. /usr/local/bin), then restart the runner.", "error", err) + return + } + + // fduty is present → keep it current via its OWN self-update, off the hot + // path. No-op when already latest; all failures are swallowed. + fdutySelfUpdate() +} + +// fdutySelfUpdate runs the CLI's built-in `fduty update`, which resolves the +// latest version from its mirror and, if newer, reinstalls in place via the +// CLI's install.sh. We pin install.sh's target to OUR tools dir and binary name +// so the upgrade lands exactly where the bash PATH resolves `fduty`, and point it +// at the runner's own mirror when known (so a private/regional deployment +// upgrades from where it was provisioned). Best-effort: a missing network, curl, +// or write permission just logs and returns. +func fdutySelfUpdate() { + dir := environment.BundledToolsDir() + if dir == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), fdutyUpdateTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "bash", "-c", "fduty update") + cmd.WaitDelay = 10 * time.Second + // BashToolEnv scrubs every FLASHDUTY_* var, so re-supply the ones install.sh + // needs AFTER it. INSTALLED_NAME=fduty matches the runner/install.sh convention. + env := append(environment.BashToolEnv(), + "FLASHDUTY_INSTALL_DIR="+dir, + "INSTALLED_NAME=fduty", + ) + if base := fdutyUpdateBaseURL(); base != "" { + env = append(env, "FLASHDUTY_UPDATE_BASE_URL="+base) + } + cmd.Env = env + + var out bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &out + if err := cmd.Run(); err != nil { + slog.Info("fduty self-update skipped (non-fatal)", "error", err, "output_head", head(out.String(), 300)) + return + } + slog.Info("fduty self-update checked", "output_head", head(out.String(), 300)) +} + +// fdutyUpdateBaseURL derives the mirror base `fduty update` should pull from, so +// the CLI upgrades from the SAME place the runner was provisioned rather than the +// CLI's public default. It strips a trailing "/install.sh" off the runner's +// configured fduty install URL (FLASHDUTY_CLI_INSTALL_URL, else the baked +// cliInstallURL). Empty when neither is set — `fduty update` then uses its own +// default/env resolution. +func fdutyUpdateBaseURL() string { + u := os.Getenv("FLASHDUTY_CLI_INSTALL_URL") + if u == "" { + u = cliInstallURL + } + if u == "" { + return "" } + return strings.TrimSuffix(strings.TrimSuffix(u, "/"), "/install.sh") } // provisionFduty places an fduty binary at target (inside the bundled-tools @@ -160,8 +234,7 @@ func installFdutyFromCDN(dir, target string) error { "INSTALLED_NAME=fduty", ) var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &out + cmd.Stdout, cmd.Stderr = &out, &out if err := cmd.Run(); err != nil { return fmt.Errorf("fduty CLI install failed: %w; output: %s", err, out.String()) } @@ -190,8 +263,7 @@ func verifyFdutyOnPath() error { cmd := exec.CommandContext(ctx, "bash", "-c", "fduty version") cmd.Env = environment.BashToolEnv() var out bytes.Buffer - cmd.Stdout = &out - cmd.Stderr = &out + cmd.Stdout, cmd.Stderr = &out, &out if err := cmd.Run(); err != nil { return fmt.Errorf("fduty CLI self-check failed (`fduty version` did not exit 0 on the bash PATH; "+ "agent CLI calls would 127): %w; output: %s", err, out.String()) diff --git a/cmd/provision_test.go b/cmd/provision_test.go index d3a44bb..520febc 100644 --- a/cmd/provision_test.go +++ b/cmd/provision_test.go @@ -103,6 +103,50 @@ func TestEnsureFdutyCLI_NeverFatalWhenUnprovisionable(t *testing.T) { ensureFdutyCLI() // reaching here is the assertion — os.Exit/log.Fatal would kill the binary first } +// fdutyUpdateBaseURL strips the install-script suffix so `fduty update` pulls +// from the same mirror the runner was provisioned from; env wins over the baked +// default; empty when neither is configured. +func TestFdutyUpdateBaseURL(t *testing.T) { + saved := cliInstallURL + t.Cleanup(func() { cliInstallURL = saved }) + + t.Run("from env, strips /install.sh and trailing slash", func(t *testing.T) { + cliInstallURL = "" + t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "https://mirror.example/flashduty-cli/install.sh") + assert.Equal(t, "https://mirror.example/flashduty-cli", fdutyUpdateBaseURL()) + }) + t.Run("env wins over baked", func(t *testing.T) { + cliInstallURL = "https://baked.example/flashduty-cli/install.sh" + t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "https://env.example/flashduty-cli/install.sh") + assert.Equal(t, "https://env.example/flashduty-cli", fdutyUpdateBaseURL()) + }) + t.Run("falls back to baked when env empty", func(t *testing.T) { + cliInstallURL = "https://baked.example/flashduty-cli/install.sh" + t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "") + assert.Equal(t, "https://baked.example/flashduty-cli", fdutyUpdateBaseURL()) + }) + t.Run("empty when neither set", func(t *testing.T) { + cliInstallURL = "" + t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "") + assert.Equal(t, "", fdutyUpdateBaseURL()) + }) +} + +// fdutySelfUpdate must run `fduty update` against the resolved fduty and never +// panic/abort, even though the stub `update` is a no-op. Exercises the full +// invocation (env assembly, bash resolution, output capture). +func TestFdutySelfUpdate_RunsAgainstStubAndIsNonFatal(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("stub uses a POSIX shebang") + } + binDir := t.TempDir() + writeStubFduty(t, binDir, 0) // stub ignores args, exits 0 on `fduty update` + t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", binDir) + t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "") + + fdutySelfUpdate() // must return without panicking +} + // verifyFdutyOnPath runs `fduty version` through the bash tool env and gates on // exit 0. With a fake fduty placed in the tools dir (FLASHDUTY_RUNNER_BIN_DIR), // the bundled-tools dir is first on PATH, so bare `fduty` resolves to ours. diff --git a/cmd/serve.go b/cmd/serve.go index 47f725e..1ccf07f 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -62,10 +62,10 @@ func runServe() error { return fmt.Errorf("pin runner home: %w", err) } - // Best-effort: stage the `fduty` CLI onto the bash PATH and log a manual- - // install hint if it is missing. Deliberately non-fatal — the runner must - // start even without fduty (it can still do non-fduty work); see ensureFdutyCLI. - ensureFdutyCLI() + // Side mode: stage + self-update the `fduty` CLI on a background goroutine so + // it is OFF the startup path entirely. Non-blocking and non-fatal — the runner + // serves immediately; fduty resolves a beat later. See StartFdutyProvisioningAsync. + StartFdutyProvisioningAsync() checker := permission.NewChecker(map[string]string{"*": "allow"}) wspace, err := environment.New(workspaceRoot, checker)