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).
2526var 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".
4661func 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 ())
0 commit comments