Skip to content

Commit 2bfbbe5

Browse files
authored
Merge pull request #73 from flashcatcloud/feat/fduty-side-provision
feat(provision): auto-stage + self-update fduty CLI in side mode (Phase 2)
2 parents ef7d702 + 8d26879 commit 2bfbbe5

4 files changed

Lines changed: 142 additions & 26 deletions

File tree

cmd/main.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -265,10 +265,10 @@ func runRunner() error {
265265
}
266266
}
267267

268-
// Best-effort: stage the `fduty` CLI onto the bash PATH and log a manual-
269-
// install hint if it is missing. Deliberately non-fatal — the runner must
270-
// start even without fduty (it can still do non-fduty work); see ensureFdutyCLI.
271-
ensureFdutyCLI()
268+
// Side mode: stage + self-update the `fduty` CLI on a background goroutine so
269+
// it is OFF the startup path entirely. Non-blocking and non-fatal — the runner
270+
// starts immediately; fduty resolves a beat later. See StartFdutyProvisioningAsync.
271+
StartFdutyProvisioningAsync()
272272

273273
checker := permission.NewChecker(map[string]string{"*": "allow"})
274274

cmd/provision.go

Lines changed: 90 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"os"
1010
"os/exec"
1111
"path/filepath"
12+
"strings"
1213
"time"
1314

1415
"github.com/flashcatcloud/flashduty-runner/environment"
@@ -24,25 +25,39 @@ import (
2425
// local testing).
2526
var cliInstallURL = ""
2627

27-
// ensureFdutyCLI makes a BEST-EFFORT attempt to put the `fduty` CLI on the bash
28-
// tool's PATH, then reports whether it is usable. It NEVER aborts startup: a
29-
// runner missing fduty can still do non-fduty work, and a missing CLI is far
30-
// easier to diagnose on a running, loudly-logging runner than on one that
31-
// refuses to boot. When fduty is unavailable it logs an actionable manual-
32-
// install hint; agent commands that call `fduty` will 127 until it is resolved.
28+
// fdutyUpdateTimeout bounds the background `fduty update` network step. It is
29+
// generous — the whole ensure+update task is a detached side goroutine, never on
30+
// the startup path, so a slow CDN only delays fduty readiness, never the runner.
31+
// provisionFduty and verifyFdutyOnPath each carry their own timeouts, so no
32+
// overall wrapper is needed.
33+
const fdutyUpdateTimeout = 90 * time.Second
34+
35+
// StartFdutyProvisioningAsync launches fduty provisioning + self-update on a
36+
// detached goroutine so it is NEVER part of the runner's startup path ("side
37+
// mode"). The runner is usable for non-fduty work immediately; the fast, local
38+
// bundled-copy stage makes `fduty` resolve a beat later, and a newer release is
39+
// pulled in place if one exists. Because a runner self-update ends in a re-exec,
40+
// the next boot's side-task upgrades fduty "alongside" the runner. Every failure
41+
// is swallowed and logged — provisioning must never disturb a running runner.
42+
func StartFdutyProvisioningAsync() {
43+
go ensureFdutyCLI()
44+
}
45+
46+
// ensureFdutyCLI best-effort stages the `fduty` CLI into the bundled-tools dir,
47+
// self-checks it on the bash PATH, and — when present — keeps it current via the
48+
// CLI's own `fduty update`. It NEVER aborts anything: callers run it off the
49+
// startup path (see StartFdutyProvisioningAsync), and a missing CLI only logs an
50+
// actionable manual-install hint (agent `fduty` calls 127 until resolved).
3351
//
34-
// Auto-staging order for getting fduty into the bundled-tools dir (the dir
35-
// environment.BundledToolsDir prepends to every bash PATH — writable by
36-
// construction, see its doc comment):
37-
// 1. already present there (cloud image bakes it; install.sh stages the bundled
38-
// copy) → nothing to do;
39-
// 2. a bundled fduty shipped next to the runner executable → copy it in (no
40-
// network);
52+
// Auto-staging order for the bundled-tools dir (environment.BundledToolsDir
53+
// prepends it to every bash PATH — writable by construction):
54+
// 1. already present (cloud image bakes it; install.sh stages the bundled copy);
55+
// 2. a bundled fduty next to the runner executable → copy it in (no network);
4156
// 3. CDN install.sh fallback, when a URL is configured.
4257
//
4358
// A staging miss is not fatal — fduty may already be reachable elsewhere on the
4459
// bash PATH (e.g. /usr/local/bin from a binary-only install). verifyFdutyOnPath
45-
// is the single source of truth for "is fduty usable", and it logs the outcome.
60+
// is the single source of truth for "is fduty usable".
4661
func ensureFdutyCLI() {
4762
if dir := environment.BundledToolsDir(); dir != "" {
4863
if err := provisionFduty(dir, filepath.Join(dir, "fduty")); err != nil {
@@ -57,7 +72,66 @@ func ensureFdutyCLI() {
5772
"but agent commands that call `fduty` will fail until you install it. To fix: place an "+
5873
"executable `fduty` in the runner's tools dir ($FLASHDUTY_RUNNER_HOME/bin, default ~/.flashduty/bin) "+
5974
"or anywhere on PATH (e.g. /usr/local/bin), then restart the runner.", "error", err)
75+
return
76+
}
77+
78+
// fduty is present → keep it current via its OWN self-update, off the hot
79+
// path. No-op when already latest; all failures are swallowed.
80+
fdutySelfUpdate()
81+
}
82+
83+
// fdutySelfUpdate runs the CLI's built-in `fduty update`, which resolves the
84+
// latest version from its mirror and, if newer, reinstalls in place via the
85+
// CLI's install.sh. We pin install.sh's target to OUR tools dir and binary name
86+
// so the upgrade lands exactly where the bash PATH resolves `fduty`, and point it
87+
// at the runner's own mirror when known (so a private/regional deployment
88+
// upgrades from where it was provisioned). Best-effort: a missing network, curl,
89+
// or write permission just logs and returns.
90+
func fdutySelfUpdate() {
91+
dir := environment.BundledToolsDir()
92+
if dir == "" {
93+
return
94+
}
95+
ctx, cancel := context.WithTimeout(context.Background(), fdutyUpdateTimeout)
96+
defer cancel()
97+
98+
cmd := exec.CommandContext(ctx, "bash", "-c", "fduty update")
99+
cmd.WaitDelay = 10 * time.Second
100+
// BashToolEnv scrubs every FLASHDUTY_* var, so re-supply the ones install.sh
101+
// needs AFTER it. INSTALLED_NAME=fduty matches the runner/install.sh convention.
102+
env := append(environment.BashToolEnv(),
103+
"FLASHDUTY_INSTALL_DIR="+dir,
104+
"INSTALLED_NAME=fduty",
105+
)
106+
if base := fdutyUpdateBaseURL(); base != "" {
107+
env = append(env, "FLASHDUTY_UPDATE_BASE_URL="+base)
108+
}
109+
cmd.Env = env
110+
111+
var out bytes.Buffer
112+
cmd.Stdout, cmd.Stderr = &out, &out
113+
if err := cmd.Run(); err != nil {
114+
slog.Info("fduty self-update skipped (non-fatal)", "error", err, "output_head", head(out.String(), 300))
115+
return
116+
}
117+
slog.Info("fduty self-update checked", "output_head", head(out.String(), 300))
118+
}
119+
120+
// fdutyUpdateBaseURL derives the mirror base `fduty update` should pull from, so
121+
// the CLI upgrades from the SAME place the runner was provisioned rather than the
122+
// CLI's public default. It strips a trailing "/install.sh" off the runner's
123+
// configured fduty install URL (FLASHDUTY_CLI_INSTALL_URL, else the baked
124+
// cliInstallURL). Empty when neither is set — `fduty update` then uses its own
125+
// default/env resolution.
126+
func fdutyUpdateBaseURL() string {
127+
u := os.Getenv("FLASHDUTY_CLI_INSTALL_URL")
128+
if u == "" {
129+
u = cliInstallURL
130+
}
131+
if u == "" {
132+
return ""
60133
}
134+
return strings.TrimSuffix(strings.TrimSuffix(u, "/"), "/install.sh")
61135
}
62136

63137
// provisionFduty places an fduty binary at target (inside the bundled-tools
@@ -160,8 +234,7 @@ func installFdutyFromCDN(dir, target string) error {
160234
"INSTALLED_NAME=fduty",
161235
)
162236
var out bytes.Buffer
163-
cmd.Stdout = &out
164-
cmd.Stderr = &out
237+
cmd.Stdout, cmd.Stderr = &out, &out
165238
if err := cmd.Run(); err != nil {
166239
return fmt.Errorf("fduty CLI install failed: %w; output: %s", err, out.String())
167240
}
@@ -190,8 +263,7 @@ func verifyFdutyOnPath() error {
190263
cmd := exec.CommandContext(ctx, "bash", "-c", "fduty version")
191264
cmd.Env = environment.BashToolEnv()
192265
var out bytes.Buffer
193-
cmd.Stdout = &out
194-
cmd.Stderr = &out
266+
cmd.Stdout, cmd.Stderr = &out, &out
195267
if err := cmd.Run(); err != nil {
196268
return fmt.Errorf("fduty CLI self-check failed (`fduty version` did not exit 0 on the bash PATH; "+
197269
"agent CLI calls would 127): %w; output: %s", err, out.String())

cmd/provision_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,50 @@ func TestEnsureFdutyCLI_NeverFatalWhenUnprovisionable(t *testing.T) {
103103
ensureFdutyCLI() // reaching here is the assertion — os.Exit/log.Fatal would kill the binary first
104104
}
105105

106+
// fdutyUpdateBaseURL strips the install-script suffix so `fduty update` pulls
107+
// from the same mirror the runner was provisioned from; env wins over the baked
108+
// default; empty when neither is configured.
109+
func TestFdutyUpdateBaseURL(t *testing.T) {
110+
saved := cliInstallURL
111+
t.Cleanup(func() { cliInstallURL = saved })
112+
113+
t.Run("from env, strips /install.sh and trailing slash", func(t *testing.T) {
114+
cliInstallURL = ""
115+
t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "https://mirror.example/flashduty-cli/install.sh")
116+
assert.Equal(t, "https://mirror.example/flashduty-cli", fdutyUpdateBaseURL())
117+
})
118+
t.Run("env wins over baked", func(t *testing.T) {
119+
cliInstallURL = "https://baked.example/flashduty-cli/install.sh"
120+
t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "https://env.example/flashduty-cli/install.sh")
121+
assert.Equal(t, "https://env.example/flashduty-cli", fdutyUpdateBaseURL())
122+
})
123+
t.Run("falls back to baked when env empty", func(t *testing.T) {
124+
cliInstallURL = "https://baked.example/flashduty-cli/install.sh"
125+
t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "")
126+
assert.Equal(t, "https://baked.example/flashduty-cli", fdutyUpdateBaseURL())
127+
})
128+
t.Run("empty when neither set", func(t *testing.T) {
129+
cliInstallURL = ""
130+
t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "")
131+
assert.Equal(t, "", fdutyUpdateBaseURL())
132+
})
133+
}
134+
135+
// fdutySelfUpdate must run `fduty update` against the resolved fduty and never
136+
// panic/abort, even though the stub `update` is a no-op. Exercises the full
137+
// invocation (env assembly, bash resolution, output capture).
138+
func TestFdutySelfUpdate_RunsAgainstStubAndIsNonFatal(t *testing.T) {
139+
if runtime.GOOS == "windows" {
140+
t.Skip("stub uses a POSIX shebang")
141+
}
142+
binDir := t.TempDir()
143+
writeStubFduty(t, binDir, 0) // stub ignores args, exits 0 on `fduty update`
144+
t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", binDir)
145+
t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "")
146+
147+
fdutySelfUpdate() // must return without panicking
148+
}
149+
106150
// verifyFdutyOnPath runs `fduty version` through the bash tool env and gates on
107151
// exit 0. With a fake fduty placed in the tools dir (FLASHDUTY_RUNNER_BIN_DIR),
108152
// the bundled-tools dir is first on PATH, so bare `fduty` resolves to ours.

cmd/serve.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,10 @@ func runServe() error {
6262
return fmt.Errorf("pin runner home: %w", err)
6363
}
6464

65-
// Best-effort: stage the `fduty` CLI onto the bash PATH and log a manual-
66-
// install hint if it is missing. Deliberately non-fatal — the runner must
67-
// start even without fduty (it can still do non-fduty work); see ensureFdutyCLI.
68-
ensureFdutyCLI()
65+
// Side mode: stage + self-update the `fduty` CLI on a background goroutine so
66+
// it is OFF the startup path entirely. Non-blocking and non-fatal — the runner
67+
// serves immediately; fduty resolves a beat later. See StartFdutyProvisioningAsync.
68+
StartFdutyProvisioningAsync()
6969

7070
checker := permission.NewChecker(map[string]string{"*": "allow"})
7171
wspace, err := environment.New(workspaceRoot, checker)

0 commit comments

Comments
 (0)