diff --git a/.gitignore b/.gitignore index 1acbb16..3ee5bda 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ nohup.out dist/ +# fduty CLI bundles fetched by scripts/bundle-fduty.sh during a release build +.fduty-bundle/ + # Local agent / assistant notes — not for public repo CLAUDE.md .claude/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 067492a..85f899c 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -5,6 +5,10 @@ before: hooks: - go mod tidy - go generate ./... + # Fetch the matched-os/arch fduty CLI into .fduty-bundle/_/fduty + # so each runner archive ships it (runner stages it from disk at first run + # instead of curl|sh'ing the CDN installer). Best-effort per os/arch. + - sh scripts/bundle-fduty.sh builds: - env: @@ -32,6 +36,15 @@ archives: format_overrides: - goos: windows formats: zip + # Bundle the matched-os/arch fduty next to the runner binary in the archive + # root. A glob (not a literal path) so an os/arch the bundler skipped just + # contributes nothing rather than failing the release; the runner falls back + # to its CDN install path there. install.sh extracts it into the writable + # tools dir at install time. + files: + - src: ".fduty-bundle/{{ .Os }}_{{ .Arch }}/fduty*" + dst: . + strip_parent: true checksum: name_template: 'checksums.txt' diff --git a/cmd/main.go b/cmd/main.go index 7e0ab26..7c0dd6d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -203,9 +203,20 @@ func runRunner() error { "max_attempts", cfg.MaxAttempts, ) - // Ensure the `fduty` CLI is in the bundled-tools dir (already on bash PATH); - // no-op when the cloud image / install.sh already placed it next to the runner. - ensureFdutyCLI() + // Pin the runner home into the environment so environment.BundledToolsDir + // (which reads FLASHDUTY_RUNNER_HOME) resolves the SAME /bin even when + // the home came from the --workspace flag rather than the env. Keeps the + // install dir and the bash PATH dir from ever diverging. + if err = os.Setenv("FLASHDUTY_RUNNER_HOME", cfg.WorkspaceRoot); err != nil { + return fmt.Errorf("failed to pin runner home: %w", err) + } + + // Ensure the `fduty` CLI is in the bundled-tools dir AND resolves on the bash + // PATH. Hard-fails startup rather than serving a runner that 127s every fduty + // call; no-op staging when the cloud image / install.sh already placed it. + if err = ensureFdutyCLI(); err != nil { + return fmt.Errorf("fduty CLI not ready: %w", err) + } checker := permission.NewChecker(map[string]string{"*": "allow"}) diff --git a/cmd/provision.go b/cmd/provision.go index 380a382..b966a96 100644 --- a/cmd/provision.go +++ b/cmd/provision.go @@ -3,6 +3,8 @@ package main import ( "bytes" "context" + "fmt" + "io" "log/slog" "os" "os/exec" @@ -22,50 +24,128 @@ import ( // local testing). var cliInstallURL = "" -// ensureFdutyCLI makes sure the `fduty` CLI sits in the runner's bundled-tools -// dir — the directory environment.withBundledToolsPath already prepends to every -// bash subprocess's PATH. Cloud images and the runner install.sh place fduty -// next to the runner binary, so this is a no-op there; on local/BYOC hosts that -// lack it, install it into that same dir from the CDN. +// ensureFdutyCLI guarantees the `fduty` CLI resolves by bare name through the +// EXACT environment the bash tool runs with, then HARD-FAILS startup if it does +// not. A runner that boots "healthy" but 127s every fduty call is worse than one +// that refuses to start, so any unrecoverable problem here returns an error that +// aborts serve/run. // -// Installing into the (writable) bundled-tools dir — never /usr/local/bin — -// keeps install.sh out of its sudo branch, which would otherwise hang a -// non-interactive `curl | sh` on an unanswerable password prompt. Failures are -// logged, never fatal: the runner still serves every other tool. -func ensureFdutyCLI() { +// Resolution 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); +// 3. CDN install.sh fallback, when a URL is configured. +// +// Regardless of which branch ran — including the no-op "already provisioned" +// path — the functional self-check below runs and gates startup. +func ensureFdutyCLI() error { dir := environment.BundledToolsDir() if dir == "" { - slog.Warn("fduty CLI provisioning skipped: cannot resolve bundled-tools dir") - return + return fmt.Errorf("fduty CLI provisioning failed: cannot resolve bundled-tools dir (set FLASHDUTY_RUNNER_HOME or FLASHDUTY_RUNNER_BIN_DIR)") } target := filepath.Join(dir, "fduty") + if err := provisionFduty(dir, target); err != nil { + return err + } + + return verifyFdutyOnPath() +} + +// provisionFduty places an fduty binary at target (inside the bundled-tools +// dir) if one is not already there, trying the bundled-next-to-runner copy +// before the CDN installer. +func provisionFduty(dir, target string) error { if fi, err := os.Stat(target); err == nil && !fi.IsDir() { - return // our bundled fduty is already in place + return nil // already in place (cloud image, install.sh, or a prior run) + } + + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("fduty CLI provisioning failed: cannot create bundled-tools dir %q: %w", dir, err) + } + + // Prefer the fduty that shipped in the release tarball, sitting next to the + // runner executable. Copying it in avoids the boot-time network + installer + // fragility entirely. + if bundled := bundledFdutyNextToExe(); bundled != "" { + if err := copyExecutable(bundled, target); err != nil { + slog.Warn("failed to stage bundled fduty; falling back to installer", "src", bundled, "error", err) + } else { + slog.Info("staged bundled fduty CLI", "path", target) + return nil + } + } + + return installFdutyFromCDN(dir, target) +} + +// bundledFdutyNextToExe returns the path to an fduty binary shipped alongside +// the runner executable, or "" if none is there. +func bundledFdutyNextToExe() string { + exe, err := os.Executable() + if err != nil { + return "" + } + cand := filepath.Join(filepath.Dir(exe), "fduty") + if fi, err := os.Stat(cand); err == nil && !fi.IsDir() { + return cand + } + return "" +} + +// copyExecutable copies src to dst with 0755 perms, via a temp file + rename so +// a concurrent reader never sees a half-written binary. +func copyExecutable(src, dst string) error { + in, err := os.Open(src) //nolint:gosec // G304: src is the runner's own bundled binary path + if err != nil { + return err + } + defer func() { _ = in.Close() }() + + tmp, err := os.CreateTemp(filepath.Dir(dst), ".fduty-*") + if err != nil { + return err } + tmpName := tmp.Name() + defer func() { _ = os.Remove(tmpName) }() // no-op once renamed + if _, err := io.Copy(tmp, in); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Chmod(0o755); err != nil { + _ = tmp.Close() + return err + } + if err := tmp.Close(); err != nil { + return err + } + return os.Rename(tmpName, dst) +} + +// installFdutyFromCDN is the network fallback: it runs flashduty-cli's install.sh +// targeting the writable bundled-tools dir, so install.sh never relocates to +// ~/.local/bin (unwritable-target branch) or takes its sudo branch. +func installFdutyFromCDN(dir, target string) error { installURL := os.Getenv("FLASHDUTY_CLI_INSTALL_URL") if installURL == "" { installURL = cliInstallURL } if installURL == "" { - slog.Warn("fduty CLI not found and no install URL configured; agent CLI calls may fail until it is installed", - "hint", "set FLASHDUTY_CLI_INSTALL_URL or build with -X main.cliInstallURL=...") - return - } - if err := os.MkdirAll(dir, 0o755); err != nil { - slog.Warn("fduty CLI install skipped: cannot create bundled-tools dir", "dir", dir, "error", err) - return + return fmt.Errorf("fduty CLI not found in %q, no bundled copy next to the runner, and no install URL configured "+ + "(set FLASHDUTY_CLI_INSTALL_URL or build with -X main.cliInstallURL=...)", dir) } - slog.Info("installing fduty CLI", "url", installURL, "dir", dir) + slog.Info("installing fduty CLI from CDN", "url", installURL, "dir", dir) ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - // Install into the writable bundled-tools dir so install.sh never takes its - // sudo branch. The URL is passed through the environment, never interpolated - // into the script, so a `$(...)` or backtick in it can't be evaluated by the - // shell. INSTALLED_NAME=fduty matches the cloud image / install.sh convention. + // The URL is passed through the environment, never interpolated into the + // script, so a `$(...)` or backtick in it can't be evaluated by the shell. + // INSTALLED_NAME=fduty matches the cloud image / install.sh convention. const script = `set -e; curl -fsSL --connect-timeout 10 --max-time 80 "${_INSTALL_URL}" | sh` cmd := exec.CommandContext(ctx, "sh", "-c", script) cmd.WaitDelay = 10 * time.Second // bound the wait so a stuck child can't block startup past ctx @@ -78,13 +158,47 @@ func ensureFdutyCLI() { cmd.Stdout = &out cmd.Stderr = &out if err := cmd.Run(); err != nil { - slog.Warn("fduty CLI install failed; agent CLI calls may fail", "error", err, "output", out.String()) - return + return fmt.Errorf("fduty CLI install failed: %w; output: %s", err, out.String()) } - if fi, err := os.Stat(target); err == nil && !fi.IsDir() { - slog.Info("fduty CLI installed", "path", target) - } else { - slog.Warn("fduty CLI install ran but binary is not in the bundled-tools dir", "dir", dir, "output", out.String()) + if fi, err := os.Stat(target); err != nil || fi.IsDir() { + return fmt.Errorf("fduty CLI installer ran but no binary landed in %q (install.sh may have relocated it); output: %s", dir, out.String()) + } + slog.Info("fduty CLI installed", "path", target) + return nil +} + +// verifyFdutyOnPath runs `fduty version` through the EXACT environment the bash +// tool uses (environment.BashToolEnv: secrets scrubbed, bundled-tools dir first +// on PATH) and asserts exit 0. This is the load-bearing gate: it catches the +// real failure mode — fduty installed somewhere that is not on the bash PATH, so +// bare `fduty` 127s at agent call time — which a plain os.Stat would miss. +func verifyFdutyOnPath() error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Resolve `fduty` through bash exactly like the bash tool does + // (environment.executeBashCommand runs `bash -c `). We must NOT run + // exec.Command("fduty", ...) directly: Go's LookPath resolves the program + // against the PARENT process PATH, ignoring cmd.Env — so it would miss the + // bundled-tools dir and not mirror the real call-time resolution. + cmd := exec.CommandContext(ctx, "bash", "-c", "fduty version") + cmd.Env = environment.BashToolEnv() + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &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()) + } + slog.Info("fduty CLI self-check passed", "output_head", head(out.String(), 200)) + return nil +} + +// head returns at most n bytes of s, for terse log lines. +func head(s string, n int) string { + if len(s) > n { + return s[:n] } + return s } diff --git a/cmd/provision_test.go b/cmd/provision_test.go new file mode 100644 index 0000000..7b4f304 --- /dev/null +++ b/cmd/provision_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "os" + "path/filepath" + "runtime" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// copyExecutable must produce a runnable copy (right bytes, +x) so the bundled +// fduty staged from next to the runner binary actually executes. +func TestCopyExecutable(t *testing.T) { + dir := t.TempDir() + src := filepath.Join(dir, "src") + require.NoError(t, os.WriteFile(src, []byte("#!/bin/sh\necho hi\n"), 0o600)) + + dst := filepath.Join(dir, "sub", "dst") // parent must already exist for CreateTemp + require.NoError(t, os.MkdirAll(filepath.Dir(dst), 0o755)) + + require.NoError(t, copyExecutable(src, dst)) + + got, err := os.ReadFile(dst) + require.NoError(t, err) + assert.Equal(t, "#!/bin/sh\necho hi\n", string(got)) + + if runtime.GOOS != "windows" { + // Unix perm bits are meaningful here; Windows doesn't model an exec bit. + fi, err := os.Stat(dst) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o100), fi.Mode().Perm()&0o100, "dst must be owner-executable") + } +} + +// bundledFdutyNextToExe returns the sibling fduty path only when one exists. +// We can't relocate the test binary, so assert the no-sibling case is "". +func TestBundledFdutyNextToExe_AbsentReturnsEmpty(t *testing.T) { + exe, err := os.Executable() + require.NoError(t, err) + if _, statErr := os.Stat(filepath.Join(filepath.Dir(exe), "fduty")); statErr == nil { + t.Skip("a real fduty sits next to the test binary; can't assert the absent case") + } + assert.Equal(t, "", bundledFdutyNextToExe()) +} + +// provisionFduty is a no-op when fduty is already in the tools dir — the cloud +// image / install.sh "already provisioned" soft path. +func TestProvisionFduty_AlreadyPresentIsNoop(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "fduty") + require.NoError(t, os.WriteFile(target, []byte("baked"), 0o755)) + + require.NoError(t, provisionFduty(dir, target)) + + got, err := os.ReadFile(target) + require.NoError(t, err) + assert.Equal(t, "baked", string(got), "must not overwrite a present fduty") +} + +// provisionFduty must HARD-FAIL (return error) when fduty is absent, no bundled +// copy sits next to the runner, and no install URL is configured — not silently +// succeed, which is the whole point of the redesign. +func TestProvisionFduty_NoSourceErrors(t *testing.T) { + if bundledFdutyNextToExe() != "" { + t.Skip("a real fduty sits next to the test binary; the bundled branch would succeed") + } + t.Setenv("FLASHDUTY_CLI_INSTALL_URL", "") + saved := cliInstallURL + cliInstallURL = "" + t.Cleanup(func() { cliInstallURL = saved }) + + dir := t.TempDir() + target := filepath.Join(dir, "fduty") + + err := provisionFduty(dir, target) + require.Error(t, err) + assert.Contains(t, err.Error(), "no install URL configured") +} + +// 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. +func TestVerifyFdutyOnPath_PassesWithStubOnPath(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("stub uses a POSIX shebang") + } + binDir := t.TempDir() + writeStubFduty(t, binDir, 0) + t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", binDir) + + require.NoError(t, verifyFdutyOnPath()) +} + +// verifyFdutyOnPath must return an error when the resolved fduty exits non-zero +// (a broken binary is as bad as a missing one). +func TestVerifyFdutyOnPath_FailsWhenStubExitsNonZero(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("stub uses a POSIX shebang") + } + binDir := t.TempDir() + writeStubFduty(t, binDir, 3) + t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", binDir) + + err := verifyFdutyOnPath() + require.Error(t, err) + assert.Contains(t, err.Error(), "self-check failed") +} + +// writeStubFduty drops an executable `fduty` in dir that prints a banner and +// exits with the given code, so the dir-first-on-PATH stub shadows any real one. +func writeStubFduty(t *testing.T, dir string, exit int) { + t.Helper() + script := "#!/bin/sh\necho 'fduty stub version 0.0.0'\nexit " + strconv.Itoa(exit) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "fduty"), []byte(script), 0o755)) +} diff --git a/cmd/serve.go b/cmd/serve.go index 793ebbb..0b4c3d3 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -49,9 +49,19 @@ func runServe() error { workspaceRoot = filepath.Join(homeDir, ".flashduty") } - // Ensure the `fduty` CLI is in the bundled-tools dir (already on bash PATH); - // no-op when the cloud image / install.sh already placed it next to the runner. - ensureFdutyCLI() + // Pin the runner home into the environment so environment.BundledToolsDir + // resolves the SAME /bin the workspace uses (keeps install dir and bash + // PATH dir from diverging). No-op when FLASHDUTY_RUNNER_HOME was already set. + if err := os.Setenv("FLASHDUTY_RUNNER_HOME", workspaceRoot); err != nil { + return fmt.Errorf("pin runner home: %w", err) + } + + // Ensure the `fduty` CLI is in the bundled-tools dir AND resolves on the bash + // PATH. Hard-fails startup rather than serving a runner that 127s every fduty + // call; no-op staging when the cloud image / install.sh already placed it. + if err := ensureFdutyCLI(); err != nil { + return fmt.Errorf("fduty CLI not ready: %w", err) + } checker := permission.NewChecker(map[string]string{"*": "allow"}) wspace, err := environment.New(workspaceRoot, checker) diff --git a/environment/bundled_tools_path_test.go b/environment/bundled_tools_path_test.go index 61acd39..9cf07e1 100644 --- a/environment/bundled_tools_path_test.go +++ b/environment/bundled_tools_path_test.go @@ -1,11 +1,56 @@ package environment import ( + "os" + "path/filepath" "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) +// BundledToolsDir must resolve to a single, runner-owned, writable directory so +// the install dir and the bash PATH dir can never diverge. Default is +// /bin (home from FLASHDUTY_RUNNER_HOME, deprecated +// FLASHDUTY_RUNNER_WORKSPACE alias, else ~/.flashduty); FLASHDUTY_RUNNER_BIN_DIR +// overrides outright (the AGS cloud image points it at /usr/local/bin). +func TestBundledToolsDir(t *testing.T) { + t.Run("FLASHDUTY_RUNNER_BIN_DIR overrides outright", func(t *testing.T) { + override := filepath.Join("usr", "local", "bin") + t.Setenv("FLASHDUTY_RUNNER_HOME", filepath.Join("var", "lib", "runner", "workspace")) + t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", override) + assert.Equal(t, override, BundledToolsDir()) + }) + + t.Run("defaults to FLASHDUTY_RUNNER_HOME/bin", func(t *testing.T) { + home := filepath.Join("var", "lib", "runner", "workspace") + t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", "") + t.Setenv("FLASHDUTY_RUNNER_WORKSPACE", "") + t.Setenv("FLASHDUTY_RUNNER_HOME", home) + assert.Equal(t, filepath.Join(home, "bin"), BundledToolsDir()) + }) + + t.Run("falls back to deprecated FLASHDUTY_RUNNER_WORKSPACE alias", func(t *testing.T) { + home := filepath.Join("srv", "runner") + t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", "") + t.Setenv("FLASHDUTY_RUNNER_HOME", "") + t.Setenv("FLASHDUTY_RUNNER_WORKSPACE", home) + assert.Equal(t, filepath.Join(home, "bin"), BundledToolsDir()) + }) + + t.Run("falls back to ~/.flashduty/bin when no env set", func(t *testing.T) { + t.Setenv("FLASHDUTY_RUNNER_BIN_DIR", "") + t.Setenv("FLASHDUTY_RUNNER_HOME", "") + t.Setenv("FLASHDUTY_RUNNER_WORKSPACE", "") + // Compute the expectation from os.UserHomeDir (the same call the code + // makes) rather than overriding $HOME — on Windows the home comes from + // %USERPROFILE%, not $HOME, so an env override would be silently ignored. + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".flashduty", "bin"), BundledToolsDir()) + }) +} + // withBundledToolsPath must put the bundled-tools dir first on PATH so our // flashduty CLI shadows any same-named binary later on a BYOC host's PATH — // while staying a no-op when there's nothing to do. diff --git a/environment/environment.go b/environment/environment.go index fd1b570..54df7ea 100644 --- a/environment/environment.go +++ b/environment/environment.go @@ -646,31 +646,72 @@ func (e *Environment) executeBashCommand(ctx context.Context, command, workdir s cmd := exec.CommandContext(ctx, "bash", "-c", command) //nolint:gosec // G204: command is user-initiated via workspace tool cmd.Dir = workdir - merged := scrubFlashdutySecrets(os.Environ()) + merged := BashToolEnv() for k, v := range extraEnv { merged = append(merged, k+"="+v) } - cmd.Env = withBundledToolsPath(merged, BundledToolsDir()) + cmd.Env = merged return runCapturedCommand(ctx, cmd) } +// BashToolEnv returns the exact base environment every bash tool subprocess +// runs with: ambient Flashduty secrets scrubbed, and the bundled-tools dir +// prepended to PATH so OUR fduty resolves. Per-call extraEnv (e.g. the bash +// guard re-supplying credentials) is layered on top by the caller. Startup's +// fduty self-check uses this so it validates the real call-time PATH, not an +// approximation. +func BashToolEnv() []string { + return withBundledToolsPath(scrubFlashdutySecrets(os.Environ()), BundledToolsDir()) +} + // BundledToolsDir returns the directory holding the CLIs the runner ships with // it — the flashduty CLI in particular. Bash subprocesses must resolve OUR // bundled flashduty rather than whatever a BYOC host happens to have earlier on // PATH (a different version, or an unrelated binary of the same name). -// FLASHDUTY_RUNNER_BIN_DIR overrides the location; the default is the directory -// the runner executable lives in, since both the container image and install.sh -// place the CLI next to the runner binary. Returns "" when the path can't be -// determined, in which case PATH is left untouched. +// +// The same value serves two roles that MUST NOT diverge: the dir we prepend to +// every bash subprocess's PATH (so fduty resolves), and the dir we install +// fduty into (FLASHDUTY_INSTALL_DIR in provision.go). If the PATH dir is +// read-only, install.sh relocates fduty to ~/.local/bin — which is not on the +// bash PATH — and every `fduty` call 127s. To make divergence impossible by +// construction, the default is a runner-owned, guaranteed-writable directory +// under the runner's state home: /bin, where the state home is the +// same root the runner uses for its workspace (FLASHDUTY_RUNNER_HOME, with the +// deprecated FLASHDUTY_RUNNER_WORKSPACE alias, falling back to ~/.flashduty). +// Under the systemd unit that root sits inside ReadWritePaths, so it is +// writable even with ProtectSystem=strict and NoNewPrivileges. +// +// FLASHDUTY_RUNNER_BIN_DIR is an explicit override and wins outright — the AGS +// cloud image bakes fduty at /usr/local/bin and points this env there. +// +// Returns "" only when neither an override nor a home dir can be resolved, in +// which case PATH is left untouched. func BundledToolsDir() string { if d := os.Getenv("FLASHDUTY_RUNNER_BIN_DIR"); d != "" { return d } - exe, err := os.Executable() + home := runnerStateHome() + if home == "" { + return "" + } + return filepath.Join(home, "bin") +} + +// runnerStateHome resolves the runner's state/home root the same way cmd does +// (FLASHDUTY_RUNNER_HOME > deprecated FLASHDUTY_RUNNER_WORKSPACE > ~/.flashduty), +// so the bundled-tools dir always lands inside the writable state tree. +func runnerStateHome() string { + if d := os.Getenv("FLASHDUTY_RUNNER_HOME"); d != "" { + return d + } + if d := os.Getenv("FLASHDUTY_RUNNER_WORKSPACE"); d != "" { + return d + } + home, err := os.UserHomeDir() if err != nil { return "" } - return filepath.Dir(exe) + return filepath.Join(home, ".flashduty") } // withBundledToolsPath returns env with dir prepended to its PATH entry so the diff --git a/install.sh b/install.sh index 1f67cf6..955d6f3 100755 --- a/install.sh +++ b/install.sh @@ -35,6 +35,12 @@ CONFIG_DIR="/etc/flashduty-runner" ENV_FILE="${CONFIG_DIR}/env" STATE_DIR="/var/lib/flashduty-runner" WORKSPACE_DIR="${STATE_DIR}/workspace" +# Runner-owned, writable dir for the bundled CLIs (fduty). MUST equal the +# runner's BundledToolsDir() at runtime: that defaults to /bin, and +# the env file below points the runner home at WORKSPACE_DIR — so the runtime +# tools dir is "${WORKSPACE_DIR}/bin". It sits under ReadWritePaths=${STATE_DIR}, +# so it stays writable with ProtectSystem=strict (no ~/.local/bin relocation). +BIN_DIR="${WORKSPACE_DIR}/bin" SERVICE_USER="flashduty" UNIT_PATH="/etc/systemd/system/flashduty-runner.service" LOCK_FILE="/var/lock/flashduty-runner-install.lock" @@ -333,9 +339,25 @@ ensure_workdir() { if [ -L "$STATE_DIR" ]; then die 1 "${STATE_DIR} is a symlink — refusing to chown through it." fi - mkdir -p "$WORKSPACE_DIR" + mkdir -p "$WORKSPACE_DIR" "$BIN_DIR" chown -R "$SERVICE_USER":"$SERVICE_USER" "$STATE_DIR" 2>/dev/null || true - chmod 0750 "$STATE_DIR" "$WORKSPACE_DIR" + chmod 0750 "$STATE_DIR" "$WORKSPACE_DIR" "$BIN_DIR" +} + +# install_bundled_fduty stages the fduty CLI that ships inside the release +# tarball (extracted to $TMPDIR_ by download_and_verify) into the runner's +# writable tools dir, so the runner never has to curl|sh the CDN installer at +# boot. Best-effort: if the archive predates bundling (no fduty member), the +# runner falls back to its CDN install path at first run. +install_bundled_fduty() { + src="${TMPDIR_}/fduty" + if [ ! -f "$src" ]; then + info "Release archive has no bundled fduty; runner will provision it at first run" + return + fi + install -m 0755 "$src" "${BIN_DIR}/fduty" + chown "$SERVICE_USER":"$SERVICE_USER" "${BIN_DIR}/fduty" 2>/dev/null || true + info "Installed bundled fduty: ${BIN_DIR}/fduty" } ensure_token() { @@ -500,6 +522,7 @@ do_install() { ensure_user ensure_workdir + install_bundled_fduty ensure_token write_env_file diff --git a/scripts/bundle-fduty.sh b/scripts/bundle-fduty.sh new file mode 100755 index 0000000..b6d93a1 --- /dev/null +++ b/scripts/bundle-fduty.sh @@ -0,0 +1,79 @@ +#!/bin/sh +# bundle-fduty.sh — fetch the matched-OS/arch `fduty` CLI binaries and lay them +# out for goreleaser to drop into each runner release archive. +# +# Run as a goreleaser `before` hook. For every os/arch the runner ships, it +# downloads the pinned flashduty-cli release archive, extracts the +# `flashduty-cli` binary, and writes it as `fduty` to: +# +# ${OUT_DIR}/_/fduty +# +# where is goreleaser's lowercase .Os (linux|darwin|windows) and +# is .Arch (amd64|arm64) — so .goreleaser.yaml's archives.files `src` template +# (.fduty-bundle/{{ .Os }}_{{ .Arch }}/fduty) resolves directly. +# +# Bundling fduty into the runner archive lets the runner stage it from disk at +# first run instead of curl|sh'ing the CDN installer (which is the boot-time +# network + install.sh-relocation fragility the redesign removes). The runner +# still keeps the CDN path as a fallback when the bundled binary is absent, so +# this step is allowed to no-op for an os/arch whose asset is missing. + +set -eu + +# Pin a specific flashduty-cli release. Bump deliberately alongside runner +# releases; never float to "latest". +CLI_VERSION="${FDUTY_CLI_VERSION:-v1.3.4}" +CLI_REPO="${FDUTY_CLI_REPO:-flashcatcloud/flashduty-cli}" +OUT_DIR="${FDUTY_BUNDLE_DIR:-.fduty-bundle}" + +# goreleaser archive naming: {Title Os}_{x86_64|arm64}. Map our build matrix. +# Windows ships as .zip; the rest as .tar.gz. +matrix="linux:Linux:amd64:x86_64:tar.gz \ + linux:Linux:arm64:arm64:tar.gz \ + darwin:Darwin:amd64:x86_64:tar.gz \ + darwin:Darwin:arm64:arm64:tar.gz \ + windows:Windows:amd64:x86_64:zip \ + windows:Windows:arm64:arm64:zip" + +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR" + +base="https://github.com/${CLI_REPO}/releases/download/${CLI_VERSION}" + +for entry in $matrix; do + goos="${entry%%:*}"; rest="${entry#*:}" + title_os="${rest%%:*}"; rest="${rest#*:}" + goarch="${rest%%:*}"; rest="${rest#*:}" + gr_arch="${rest%%:*}"; rest="${rest#*:}" + ext="${rest%%:*}" + + asset="flashduty-cli_${title_os}_${gr_arch}.${ext}" + dest_dir="${OUT_DIR}/${goos}_${goarch}" + bin_name="fduty" + [ "$goos" = "windows" ] && bin_name="fduty.exe" + + tmp="$(mktemp -d)" + if ! curl --proto '=https' --tlsv1.2 -fsSL "${base}/${asset}" -o "${tmp}/${asset}"; then + echo "warn: could not download ${asset}; runner will CDN-install fduty for ${goos}/${goarch}" >&2 + rm -rf "$tmp" + continue + fi + + case "$ext" in + tar.gz) tar -xzf "${tmp}/${asset}" -C "$tmp" ;; + zip) unzip -q -o "${tmp}/${asset}" -d "$tmp" ;; + esac + + src_bin="${tmp}/flashduty-cli" + [ "$goos" = "windows" ] && src_bin="${tmp}/flashduty-cli.exe" + if [ ! -f "$src_bin" ]; then + echo "warn: ${asset} had no flashduty-cli binary; skipping ${goos}/${goarch}" >&2 + rm -rf "$tmp" + continue + fi + + mkdir -p "$dest_dir" + install -m 0755 "$src_bin" "${dest_dir}/${bin_name}" + echo "bundled ${CLI_VERSION} -> ${dest_dir}/${bin_name}" + rm -rf "$tmp" +done