Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"})

Expand Down
108 changes: 90 additions & 18 deletions cmd/provision.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strings"
"time"

"github.com/flashcatcloud/flashduty-runner/environment"
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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())
}
Expand Down Expand Up @@ -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())
Expand Down
44 changes: 44 additions & 0 deletions cmd/provision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading