upgrade

package
v0.14.3-dev Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 21, 2026 License: MIT Imports: 21 Imported by: 0

Documentation

Overview

Package upgrade carries the self-upgrade machinery shared by the CLI (`outpost upgrade`, `outpost rollback`) and the cloudbox-pushed daemon route (POST /admin/upgrade).

The wire shape (Envelope) is what cloudbox sends. The worker (Worker) owns serialized in-process state — one upgrade at a time, last-release-id dedup, a JSONL ledger of every attempt. The Stage / Probe helpers are the deterministic file-level steps both the CLI and the worker reuse.

Trust model: the daemon trusts cloudbox to push valid URLs and matching sha256s. The URL must be https, the sha256 must verify on download, and the candidate binary must self-identify as an outpost build via `version --json`. A future ArtifactVerifier hook (signed release manifests) is wired in but defaults to a no-op so the daemon can ship before signing infrastructure exists.

Index

Constants

View Source
const DefaultRepo = "qiangli/outpost"

DefaultRepo is the GitHub repository the direct-from-GitHub upgrade path resolves releases from when GitHubSource.Repo is empty.

Variables

View Source
var ErrNoPrevious = errors.New("no outpost.previous on disk; nothing to roll back to")

ErrNoPrevious is returned when no rollback candidate is on disk (a fresh install or a daemon that hasn't been upgraded yet).

View Source
var ErrPlatformMismatch = errors.New("candidate binary is built for a different platform than this host")

ErrPlatformMismatch is returned by Probe when the candidate's self-reported os/arch (its compile-time runtime.GOOS/GOARCH, in version --json) doesn't match the running host's. This is the definitive cross-platform guard: it rejects a wrong-platform binary even when the arch can still EXEC (e.g. a darwin-amd64 build running under Rosetta on darwin-arm64), which the incidental "exec format error" check cannot. Both sides are compile-time-baked truth, so a genuine binary can never misreport.

View Source
var ErrShortCommit = errors.New("candidate binary does not self-report the expected commit")

ErrShortCommit is returned by the Probe step when the candidate's self-reported commit doesn't match the envelope. This is the last defense against URL/SHA collisions or operator typos — we refuse to swap a binary that doesn't identify as the release we expected.

View Source
var (

	// MaxUnconfirmedBoots is how many supervised respawns with the marker
	// still unconfirmed count as a crash-loop (revert candidate) regardless
	// of the deadline. Exported + var so the supervisor watchdog and tests
	// can read/shrink it.
	MaxUnconfirmedBoots = 3
)

Functions

func ArmConfirm added in v0.9.0

func ArmConfirm(ctx context.Context, confirmPath, currentCommit string, ledger *Ledger)

ArmConfirm runs in the daemon at startup. If a pending-confirm marker exists and names THIS binary (its ToSHA matches our running commit), it waits confirmDwell and, if we're still alive, commits — ledgers a confirm_ok and clears the marker, declaring the upgrade healthy. If the marker instead names the binary we just reverted TO (FromSHA matches), it clears the now-stale marker. No marker, or a marker for some other commit, is a no-op. Intended to be launched in a goroutine bound to the daemon's run context; ctx cancellation (a restart) ends it without committing, so a binary that didn't survive the dwell never self-confirms.

func CleanupStaleSwaps

func CleanupStaleSwaps(_ string)

CleanupStaleSwaps is a no-op on Unix — the atomic-rename path leaves no temp files behind. The function exists so cross-platform callers (main.go) don't need build tags.

func ClearPendingConfirm added in v0.9.0

func ClearPendingConfirm(path string) error

ClearPendingConfirm removes the marker (best-effort; missing is fine).

func MountRoute

func MountRoute(rg *gin.RouterGroup, w *Worker)

MountRoute attaches the cloudbox-pushed self-upgrade handler at `POST /admin/upgrade` on the given route group.

**Auth model: trust the tunnel.** No bearer check at this layer. The route lives on the daemon's matrix-tunnel-fronted main HTTP server, which binds 127.0.0.1 only — cloudbox is the only entity that can reach it (through the tunnel client's loopback proxy port on the cloudbox side). This is the same model the existing /apps and /healthz routes use; adding a per-host bearer here would require a shared secret cloudbox can present, and the obvious candidate (fc.Token aka cfg.MatrixToken) is empty in real production deployments where cloudbox runs without a matrix-tunnel auth secret. Defense-in-depth lives elsewhere: the AutoUpgrade toggle (operator opt-out), the sha256 + envelope.commit checks (artifact integrity), and the Probe step (commit-match self- verify) all gate what the worker will actually do.

The route is intentionally NOT under `/api/*` — cloudbox calls it through the matrix tunnel and the daemon's main HTTP server is loopback-only-via-tunnel, so the `/api/*` cookie-auth-protected surface (which lives on the separate admin listener at :17777) can't reach it anyway. Keeping it at `/admin/upgrade` is purely a naming choice — signals "this is a cloudbox→outpost control path," not "this is an admin-UI route."

Status codes: 202 accepted, 200 replay, 400 invalid envelope, 403 auto_upgrade off, 304 same commit, 412 min_from mismatch, 409 in-flight.

func PendingConfirmPath added in v0.9.0

func PendingConfirmPath(cacheDir string) string

PendingConfirmPath is where the marker lives — next to the ledger, with a name distinct from PendingPath (the manual-mode envelope queue).

func PendingPath

func PendingPath(cacheDir string) string

PendingPath returns the canonical path to upgrade.pending.json under the given cache dir. Public so main.go and the CLI's apply-pending command can both compute it without a magic string drifting between them.

func Probe

func Probe(path, expectedCommit string) (agent.BuildInfo, error)

Probe execs `<path> version --json` and decodes the BuildInfo. This is the single check that distinguishes "an outpost binary at the expected commit" from "anything else on disk." Bad exit code, bad JSON, missing go_version, or a commit mismatch all fail closed.

`expectedCommit` is the envelope's short commit; pass "" from the CLI (which doesn't pre-commit to a sha) to skip the commit check. The worker always passes the envelope's Commit so a man-in-the- middle that substitutes a same-named differently-built binary (different go.mod, different version of dependent packages, etc.) would still need to match the released sha to land.

func QuarantinePath added in v0.9.0

func QuarantinePath(cacheDir string) string

QuarantinePath is where the quarantine set lives — next to the ledger.

func RetainPrevious

func RetainPrevious(binary, previous string) error

RetainPrevious snapshots the running binary at "<binary>.previous" before rename. Hardlink first (instant, single-fs); fall back to a copy on cross-fs or filesystems without hardlink support. Exported so the CLI's `outpost upgrade --local|--from` path can call it before its own swap — both paths leave a rollback target on disk.

func RevertToPrevious added in v0.9.0

func RevertToPrevious(binaryPath, prevPath string, ledger *Ledger, entry LedgerEntry) (agent.BuildInfo, error)

RevertToPrevious swaps prevPath over binaryPath after probing prevPath, and (if a ledger is given) records the supplied entry with ToSHA filled from the restored build. It is the local, cloudbox-free core shared by the operator path (Worker.Rollback, step "rollback") and the auto-rollback watchdog (the supervisor, step "auto_rollback") — the supervisor can't call Worker.Rollback because the daemon is dead/crash-looping when it runs.

It does NOT restart anything: Worker.Rollback re-execs the daemon afterwards, while the supervisor simply launches the now-reverted binary next. Returns ErrNoPrevious when prevPath is missing; refuses (returns an error without swapping) when the probe rejects a truncated / wrong-platform .previous, so a broken rollback target can't brick the host.

func StageFromLocal

func StageFromLocal(srcPath, dst, expectedSHA string) error

StageFromLocal copies `srcPath` into `dst`, verifying sha256 if `expectedSHA` is non-empty. CLI-only entry point; the daemon worker never reads from a local path (callers can't be trusted to deliver arbitrary paths over the wire).

func StageFromURL

func StageFromURL(ctx context.Context, dst, srcURL, expectedSHA string, client *http.Client) error

StageFromURL downloads the candidate binary at `srcURL` into `dst`, verifying the body against `expectedSHA` (hex) on the fly. Both caller-provided modes (cloudbox push and `outpost upgrade --from`) flow through here; the only difference is the daemon's worker always passes a non-empty expectedSHA while the CLI permits empty for ad-hoc test pushes from a local artifact server.

The destination is opened O_EXCL so a stale "<binary>.upgrading" from a crashed prior attempt surfaces as an error instead of silent clobber. Caller is responsible for cleaning up `dst` on downstream errors.

func SwapAtomic

func SwapAtomic(current, candidate string) error

SwapAtomic puts `candidate` at `current`, replacing whatever was there. On Unix the kernel allows os.Rename over a file that's currently being executed — the running process keeps its file open via inode reference, the path is rebound to the new file, and the next exec from the path reads the new binary. Single rename, fully atomic.

The current file is replaced; callers that want to retain the prior generation for rollback must do so BEFORE calling SwapAtomic (the Worker uses RetainPrevious to hardlink outpost.previous next to outpost first).

func WritePendingConfirm added in v0.9.0

func WritePendingConfirm(path string, pc PendingConfirm) error

WritePendingConfirm atomically writes the marker (temp + rename).

Types

type ArtifactVerifier

type ArtifactVerifier interface {
	Verify(env Envelope, candidatePath string, candidate agent.BuildInfo) error
}

ArtifactVerifier is the seam for adding signed-release-manifest verification later. Today the trust path is "HTTPS to cloudbox + sha256-in-envelope + Probe(commit-match)" — good enough while cloudbox itself is the root of trust for the fleet. When third-party release channels become a thing, an implementation will check a cryptographic signature (e.g. cosign / minisign / minimaCert) over the {commit, sha256, url} tuple before letting the worker swap the binary in.

Verify is called after Probe succeeds but before the os.Rename. A non-nil error aborts the swap (the candidate file is removed; one ledger entry is emitted with step="verify_failed" and the cause).

Default: NoopVerifier — always returns nil. The Worker.verifier field defaults to this when Options.Verifier is left zero, so today's cloudbox-as-root-of-trust model continues to work without callers having to opt in.

type Envelope

type Envelope struct {
	// ReleaseID is cloudbox's opaque identifier for this artifact
	// (e.g. "v0.42.1-abc1234"). Used purely for dedup + ledger
	// correlation — the daemon doesn't parse it.
	ReleaseID string `json:"release_id"`
	// URL must be HTTPS. The daemon refuses http://, file://, or
	// anything else.
	URL string `json:"url"`
	// SHA256 of the artifact body (hex). Mandatory here even though
	// the CLI variant treats it as optional — a cloudbox push without
	// an integrity check is not acceptable.
	SHA256 string `json:"sha256"`
	// Commit is the 7-char short sha the candidate's BuildInfo must
	// expose. Probe rejects the candidate if its self-reported
	// commit doesn't match, defending against URL/SHA collisions
	// against an unrelated build.
	Commit string `json:"commit"`
	// MinFrom optionally constrains "this upgrade only applies to
	// hosts already running at least this commit." The daemon
	// returns 412 if its current commit is older. Useful when a
	// release requires migration state from a prior version.
	MinFrom string `json:"min_from,omitempty"`

	// Force bypasses the daemon's update_mode gate. Set by cloudbox
	// (or the local `outpost upgrade apply` CLI) when the OPERATOR
	// has explicitly chosen to apply this envelope right now — a
	// "manual" host that gets Force=true behaves like an "auto"
	// host for this one push. Never set by the GH-Action release
	// webhook (those are advisory, not operator-blessed). "never"
	// mode still refuses Force=true; the operator must flip the
	// mode first.
	Force bool `json:"force,omitempty"`
}

Envelope is what cloudbox POSTs to /admin/upgrade. Every field except MinFrom is required; the daemon returns 400 if any of {release_id, url, sha256, commit} is empty.

func ReadPending

func ReadPending(path string) (*Envelope, error)

ReadPending is the public read variant — used by the apply-pending MCP tool / CLI to fetch the queued envelope before re-submitting it with Force=true.

func (Envelope) Validate

func (e Envelope) Validate() error

Validate enforces non-empty required fields. Schema-level validity only — does not contact the network or filesystem.

type GitHubSource added in v0.8.0

type GitHubSource struct {
	// Repo is "owner/name"; empty → DefaultRepo.
	Repo string
	// Platform is "<goos>_<goarch>" (e.g. "darwin_arm64"), matching the
	// release asset naming `outpost-<tag>-<goos>-<goarch>[.exe]` from
	// .github/workflows/release.yml.
	Platform string
	// HTTPClient for api.github.com calls + the sidecar download; nil →
	// http.DefaultClient.
	HTTPClient *http.Client
	// Token, when set, is sent as a Bearer to api.github.com to lift the
	// 60-req/hr unauthenticated rate limit. Optional — the public
	// releases API works unauthenticated for the handful of calls a
	// single interactive upgrade makes.
	Token string
	// contains filtered or unexported fields
}

GitHubSource resolves the latest published release of an outpost repo into an Envelope the Worker / CLI swap flow can apply WITHOUT a cloudbox in the loop.

This is the fallback upgrade authority for the two cases the cloudbox push/pull path structurally can't serve:

  • an UNPAIRED host — no access_token, so no /api/v1/fleet/target to poll. `outpost upgrade` with no --from/--local resolves here.
  • the `outpost upgrade --direct` operator escape hatch on a paired host — deliberately bypassing fleet governance. An interactive operator standing at the box already holds OS-level authority over it, the same authority `outpost upgrade --from <url>` has always assumed; --direct is just that with the URL resolved for them.

It produces the SAME Envelope shape the cloudbox release webhook produces, so everything downstream is unchanged: StageFromURL verifies the sha256, Probe rejects a candidate whose self-reported commit doesn't match, RetainPrevious leaves a rollback copy, then the atomic swap. The only thing that shifts is the artifact owner — GitHub Releases over HTTPS instead of cloudbox.

Deliberately NOT wired into an automatic background poller: a paired host's automatic upgrades stay governed by the fleet (so cloudbox keeps controlling canary→fleet rollout, update_mode, and min_from fences), and an unpaired host self-upgrading silently could surprise an operator who pinned a build on purpose. Direct resolution is an explicit, interactive action.

func (GitHubSource) Resolve added in v0.8.0

func (g GitHubSource) Resolve(ctx context.Context) (Envelope, error)

Resolve fetches the latest release for the configured platform and returns a ready-to-apply Envelope. Errors are phrased for an operator reading them straight off the terminal (no matching asset, rate limited, etc.).

type Ledger

type Ledger struct {
	// contains filtered or unexported fields
}

Ledger is an append-only JSONL writer + bounded tail-reader. Path is fixed at construction; concurrent appends serialize through mu.

func NewLedger

func NewLedger(path string) *Ledger

NewLedger returns a Ledger backed by `path`. Doesn't touch the filesystem until the first Append.

func (*Ledger) Append

func (l *Ledger) Append(entry LedgerEntry) error

Append writes one entry as a single JSON line. If `entry.At` is zero, it is filled with the current time. The file is opened O_APPEND so concurrent writers from a future fan-out don't have to coordinate seek positions — the OS handles atomic byte appends on POSIX up to PIPE_BUF, and a single JSON line never exceeds that for our shape.

Errors writing the ledger are NOT fatal to the upgrade — we'd rather complete an upgrade than abort it because we couldn't scribble a record. Callers log Append's error but continue.

func (*Ledger) Path

func (l *Ledger) Path() string

Path is exposed so callers can include it in diagnostics.

func (*Ledger) Tail

func (l *Ledger) Tail(n int) ([]LedgerEntry, error)

Tail returns up to the last `n` ledger entries, newest last. A missing ledger file returns an empty slice without error — a host that has never been upgraded simply has no history.

Implementation reads the whole file (the ledger is unbounded in principle but bounded in practice: one entry per upgrade attempt, which is rare enough that even a year of activity stays well under a megabyte). When we cross some real threshold we can rotate.

type LedgerEntry

type LedgerEntry struct {
	At        time.Time `json:"at"`
	ReleaseID string    `json:"release_id,omitempty"`
	Step      string    `json:"step"`
	FromSHA   string    `json:"from_sha,omitempty"`
	ToSHA     string    `json:"to_sha,omitempty"`
	URL       string    `json:"url,omitempty"`
	Detail    string    `json:"detail,omitempty"`
	Error     string    `json:"error,omitempty"`
}

LedgerEntry is one JSON line in the upgrade history file. Each significant phase of an upgrade (received, stage_start, swap_done, restart, failed, rollback_used) emits one of these. The file is the source of truth for "what happened to this host" — surfaced via `outpost upgrade history` and the outpost://upgrade-history MCP resource.

type NoopVerifier

type NoopVerifier struct{}

NoopVerifier is the zero-trust-cost default. Returns nil for every envelope. Replaced by a signature-checker when signed manifests land.

func (NoopVerifier) Verify

func (NoopVerifier) Verify(_ Envelope, _ string, _ agent.BuildInfo) error

Verify always returns nil.

type Options

type Options struct {
	State    State
	Restart  func()
	Ledger   *Ledger
	Client   *http.Client
	Logger   *slog.Logger
	Verifier ArtifactVerifier // nil → NoopVerifier (today's cloudbox-as-root-of-trust)
	// ConfirmPath is where run() writes the auto-rollback watchdog marker
	// after a swap. Empty disables the marker.
	ConfirmPath string
	// QuarantinePath backs the re-apply guard for auto-reverted releases.
	// Empty disables the guard.
	QuarantinePath string
}

Options configures a Worker. State and Restart are required; everything else has a sensible zero default.

type PendingConfirm added in v0.9.0

type PendingConfirm struct {
	ReleaseID       string    `json:"release_id"`
	FromSHA         string    `json:"from_sha"`    // short commit we upgraded FROM (== <binary>.previous)
	ToSHA           string    `json:"to_sha"`      // short commit we upgraded TO (the new binary)
	PrevPath        string    `json:"prev_path"`   // <binary>.previous, the revert target
	BinaryPath      string    `json:"binary_path"` // live binary
	SwappedAt       time.Time `json:"swapped_at"`
	ConfirmDeadline time.Time `json:"confirm_deadline"`
	BootCount       int       `json:"boot_count"` // supervised respawns observed still-unconfirmed
}

PendingConfirm is the marker an in-flight upgrade leaves on disk so the new binary can be confirmed healthy — or reverted if it never is.

func NewPendingConfirm added in v0.9.0

func NewPendingConfirm(releaseID, fromSHA, toSHA, binaryPath, prevPath string) PendingConfirm

NewPendingConfirm builds the marker an upgrade leaves after a swap.

func ReadPendingConfirm added in v0.9.0

func ReadPendingConfirm(path string) (*PendingConfirm, error)

ReadPendingConfirm returns the marker, or (nil, nil) when there is none.

type PullerConfig added in v0.7.2

type PullerConfig struct {
	// CloudboxBase is the cloudbox origin (scheme+host), e.g.
	// https://ai.dhnt.io — the same base the ollama watcher / backup
	// pusher use. The /api/v1/fleet/target path is appended.
	CloudboxBase string
	// AccessToken is the per-outpost bearer presented to cloudbox.
	AccessToken string
	// Platform selects the artifact, in <goos>_<goarch> form matching
	// the release webhook's artifact map keys (e.g. "windows_amd64").
	Platform string
	// Worker applies any envelope the poll returns.
	Worker *Worker
	// Interval between polls. <=0 → defaultPullInterval.
	Interval time.Duration
	// InitialDelay before the first poll (lets the tunnel settle after
	// a fresh start or wake). <0 → a random delay in [0, Interval) so a
	// fleet rebooted together doesn't poll in lockstep; 0 polls
	// immediately (used by tests).
	InitialDelay time.Duration
	// HTTPClient is the client used for the target GET. nil →
	// http.DefaultClient.
	HTTPClient *http.Client
}

PullerConfig configures and runs a Puller. CloudboxBase, AccessToken, Platform, and Worker are required; the rest take defaults.

func (PullerConfig) Run added in v0.7.2

func (p PullerConfig) Run(ctx context.Context) error

Run blocks until ctx is canceled, polling the fleet target on the configured cadence. It returns nil on cancel. A misconfigured Puller (unpaired host, no worker) logs once and blocks — it never errors the errgroup it runs under. Note that a successful apply restarts the daemon, which cancels ctx and ends this goroutine.

type Quarantine added in v0.9.0

type Quarantine struct {
	// contains filtered or unexported fields
}

Quarantine records release_ids that were auto-reverted on this host, so the puller doesn't immediately re-pull and re-brick the same bad release in a slow flap. It is the cross-process channel between the supervisor (which adds an entry at revert time) and the daemon's Worker.Apply (which refuses a quarantined release). Cleared only by an operator (`outpost upgrade unquarantine`) or superseded by cloudbox publishing a different release_id.

func NewQuarantine added in v0.9.0

func NewQuarantine(path string) *Quarantine

NewQuarantine returns a Quarantine backed by path. Doesn't touch the filesystem until the first Add/Has.

func (*Quarantine) Add added in v0.9.0

func (q *Quarantine) Add(e QuarantineEntry) error

Add quarantines a release (read-modify-write, atomic). RevertedAt is stamped if unset.

func (*Quarantine) Clear added in v0.9.0

func (q *Quarantine) Clear(releaseID string) error

Clear removes one release from quarantine. Missing is not an error.

func (*Quarantine) ClearAll added in v0.9.0

func (q *Quarantine) ClearAll() error

ClearAll empties the quarantine set.

func (*Quarantine) Has added in v0.9.0

func (q *Quarantine) Has(releaseID string) bool

Has reports whether releaseID is quarantined. Reads the file each call so an entry the supervisor wrote during a revert is seen by the freshly restarted daemon. A read error fails OPEN (returns false): the watchdog is the backstop, and a re-bricked release just re-reverts — better than a corrupt quarantine file wedging all future upgrades.

func (*Quarantine) List added in v0.9.0

func (q *Quarantine) List() ([]QuarantineEntry, error)

List returns all quarantined entries (unordered).

func (*Quarantine) Path added in v0.9.0

func (q *Quarantine) Path() string

Path is exposed for diagnostics.

type QuarantineEntry added in v0.9.0

type QuarantineEntry struct {
	ReleaseID  string    `json:"release_id"`
	Commit     string    `json:"commit,omitempty"`
	RevertedAt time.Time `json:"reverted_at"`
	Reason     string    `json:"reason,omitempty"`
}

QuarantineEntry is one quarantined release, with provenance for the operator who inspects `outpost upgrade history` / the unquarantine CLI.

type Result

type Result struct {
	Status    Status `json:"status"`
	Detail    string `json:"detail,omitempty"`
	ReleaseID string `json:"release_id,omitempty"`
	Commit    string `json:"commit,omitempty"`
}

Result is what Apply returns over the wire. Status carries the outcome; Detail is a short human-readable explanation; Commit (when set) lets cloudbox correlate the response against its release metadata.

type RollbackResult

type RollbackResult struct {
	Status     string          `json:"status"` // "" on success, "no_previous"/"in_flight" on refusal
	Detail     string          `json:"detail,omitempty"`
	Previous   agent.BuildInfo `json:"previous,omitempty"` // build the rollback restored to
	FromCommit string          `json:"from_commit,omitempty"`
}

RollbackResult is what Rollback returns to its caller (the MCP tool and CLI alike). Empty Status + Detail when the rollback was applied; non-empty Status when refused.

type State

type State func() StateSnapshot

State exposes the running daemon's knowledge that the upgrade worker needs to make decisions on each Apply call. Threaded as a closure so the worker doesn't have to import admincore/conf and pull in their construction graph for tests.

type StateSnapshot

type StateSnapshot struct {
	// UpdateMode is the per-host policy for incoming pushes. One of
	// "auto", "manual", "never" (the conf.UpdateMode* constants;
	// empty is treated as "auto"). See conf/file.go for the contract.
	UpdateMode string
	// CurrentCommit is the running daemon's commit — wire it from
	// agent.ReadBuildInfo().ShortCommit(), NOT Short(): on release
	// builds Short() returns the semver tag ("v0.7.0"), which can
	// never equal an envelope's sha, silently disabling the
	// same-commit and min_from guards. Short or full sha both work;
	// Apply normalizes both sides to 7 chars before comparing.
	CurrentCommit string
	// CurrentDirty reports whether the running binary was built from a
	// working tree with uncommitted changes — i.e. a local developer
	// build carrying code that exists nowhere else. Wire it from
	// agent.ReadBuildInfo().Dirty. Apply refuses cloudbox-pushed
	// upgrades on such a binary: the swap would destroy unreleased work
	// with no way to recover it, since there's no artifact to roll back
	// to. Same reasoning as the installed-via marker below.
	CurrentDirty bool
	// BinaryPath is the live binary's on-disk location (os.Executable
	// of the daemon). The worker stages "<BinaryPath>.upgrading" next
	// to it and hardlinks the current to "<BinaryPath>.previous"
	// before rename for rollback.
	BinaryPath string
	// PendingPath is the path to upgrade.pending.json — where the
	// worker persists envelopes received while in manual mode. The
	// operator's `outpost upgrade apply` reads this file and re-POSTs
	// the envelope with Force=true to consume it.
	PendingPath string
}

StateSnapshot is what State returns each tick. The worker treats it as a momentary read — re-evaluates on each Apply call so a just-flipped update_mode toggle takes effect immediately.

type Status

type Status string

Status enumerates the outcomes Apply can return. The HTTP route layer maps these to status codes — keep the mapping in one place (see HTTPStatus below) so adding a new outcome doesn't require editing the gin handler.

const (
	StatusAccepted      Status = "accepted"       // upgrade queued; worker goroutine running
	StatusReplay        Status = "replay"         // same release_id we just handled; idempotent no-op
	StatusInFlight      Status = "in_flight"      // another upgrade is currently running
	StatusSameCommit    Status = "same_commit"    // current daemon is already on this commit
	StatusDisabled      Status = "disabled"       // operator turned update_mode to "never"
	StatusMinFrom       Status = "min_from"       // current commit is older than envelope.min_from
	StatusPendingManual Status = "pending_manual" // envelope persisted; operator must apply via UI/CLI
	StatusQuarantined   Status = "quarantined"    // release was auto-reverted on this host; refuse re-apply
)

func (Status) HTTPStatus

func (s Status) HTTPStatus() int

HTTPStatus maps an Apply outcome to the wire HTTP status. 202 Accepted covers both "work is happening async" (StatusAccepted) and "envelope was persisted, awaiting operator" (StatusPendingManual); the body's status field disambiguates. The rest are terminal refusals with a human-readable reason.

type Worker

type Worker struct {
	// contains filtered or unexported fields
}

Worker drives the cloudbox-pushed upgrade flow on the daemon side. One Worker per daemon process; the route handler routes every /admin/upgrade POST through Worker.Apply.

Invariants:

  • Only one upgrade goroutine runs at a time (enforced by inFlight).
  • Replays of the same ReleaseID return StatusReplay without doing anything, even after a prior upgrade completed — defends against cloudbox retries during the restart window when the daemon may briefly appear unresponsive.
  • All state changes funnel through Apply's lock; the worker goroutine never mutates Worker fields after it's spawned (it just appends to the ledger and calls restart).

func NewWorker

func NewWorker(opts Options) (*Worker, error)

NewWorker constructs a Worker. State and Restart are required — without State the worker can't decide anything; without Restart the upgrade can't take effect (the daemon would keep running the old binary even after the swap).

func (*Worker) Apply

func (w *Worker) Apply(ctx context.Context, env Envelope) Result

Apply is the single entry point. The route handler calls this after binding the JSON body. Returns the wire Result; the handler maps Status → HTTP code via HTTPStatus.

func (*Worker) LoadPending

func (w *Worker) LoadPending() (*Envelope, error)

LoadPending returns the queued envelope for this worker, if any. The worker captures the pending path from its State closure on every Apply; LoadPending reuses the same source-of-truth so the MCP / CLI surfaces don't need to compute the path themselves.

func (*Worker) Rollback

func (w *Worker) Rollback(ctx context.Context) (RollbackResult, error)

Rollback restores `<binary>.previous` over the live binary and triggers a restart. Refuses while another upgrade is in flight to avoid racing the inflight worker's own rename. After rollback the `.previous` file is gone; the operator must re-upgrade to climb forward again (we intentionally don't keep a "next" copy — that would require two-deep generation tracking we don't need today).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL