// Package selfupdate implements the AGENT-Go half of the operator-signed A/B self-update (TASK D1). // The ROOT half (the atomic binary flip + rollback) is the felhom-selfupdate-guarded shell wrapper // (configs/); systemd owns the crash-loop auto-rollback. This package: // - downloads the operator-pinned binary from the configured artifact host, // - verifies it against the SIGNED sha256 (the only integrity root), // - hands it to the wrapper's `apply` verb via `sudo -n` (the Executor, driven by signedjobs), // - after the NEW binary has run cleanly for the dwell, calls the wrapper's `commit` (the Manager). // // The design principle (SPIKE-agent-selfupdate-2026-07-05): the thing that performs rollback is // never the thing being updated. This package only ever STARTS an update and COMMITS a good one; // it never rolls back (that is systemd + the wrapper, so a crash-looping new binary cannot fail to // revert itself). package selfupdate import ( "encoding/json" "fmt" "log/slog" "os" "path/filepath" "regexp" ) // opAgentUpdate is the op class this executor serves (mirrors reconcile.ClassAgentUpdate; the // literal avoids importing reconcile here just for the string). const opAgentUpdate = "agent_update" // versionRe bounds the version string that is interpolated into a URL and a filename. Bare semver // with an optional pre-release suffix (e.g. 0.70.1, 0.70.2-crash) — no slashes, spaces, or dots-only. var versionRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$`) // sha256Re is the strict 64-lowercase-hex form of the signed sha. var sha256Re = regexp.MustCompile(`^[0-9a-f]{64}$`) // PendingMarker is the JSON the wrapper writes at /selfupdate/pending.json after a flip. // The agent reads it on startup to decide whether to commit. Field names match the wrapper. type PendingMarker struct { OldVersion string `json:"old_version"` NewVersion string `json:"new_version"` SHA256 string `json:"sha256"` AppliedAt string `json:"applied_at"` } // stagingDir is the agent-writable staging subdir (spike S4a: agent-owned StateDir, 0750). func stagingDir(stateDir string) string { return filepath.Join(stateDir, "selfupdate") } // pendingPath is the wrapper's pending-marker path. func pendingPath(stateDir string) string { return filepath.Join(stagingDir(stateDir), "pending.json") } // readPending returns the pending marker, or (nil, nil) when none exists. func readPending(stateDir string) (*PendingMarker, error) { data, err := os.ReadFile(pendingPath(stateDir)) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var m PendingMarker if err := json.Unmarshal(data, &m); err != nil { return nil, fmt.Errorf("selfupdate: pending marker unparseable: %w", err) } return &m, nil } // interpolateURL substitutes the (validated) version into the URL template's {version} placeholder. func interpolateURL(template, version string) string { return regexp.MustCompile(`\{version\}`).ReplaceAllString(template, version) } // noopLogger is a discard logger for nil-safety. func orDefaultLogger(l *slog.Logger) *slog.Logger { if l == nil { return slog.Default() } return l }