feat: D1 Part 2 — agent self-update Go plumbing (op class, opsign, executor, commit, report)

- reconcile: ClassAgentUpdate op class; always Destructive (no provenance
  blesses replacing the root-adjacent binary). classify test + companion
  (TestClassify_AgentUpdateAlwaysDestructive).
- opsign: `-op agent_update` with -agent-version + -sha256 (isHex64-validated);
  params {version,sha256}. isHex64 test (Group D).
- config: SelfUpdateConfig{URLTemplate,Username,Token,StateDir,DwellSeconds}
  + WithDefaults + Token redaction.
- internal/selfupdate: Executor (download → verify vs the SIGNED sha → sudo -n
  wrapper `apply`; sha is the only integrity root — mismatch refuses + removes,
  agent untouched); Manager (startup dwell → `commit`; version-mismatch → no
  commit + loud WARN + marker left for report visibility; shutdown-before-dwell
  leaves pending). WrapperRunner seam → tests never shell out.
- hub report: additive selfupdate_pending(+version) via SetSelfUpdateReporter
  seam; both omitempty (Wireguard precedent) so the cross-repo golden contract
  stays byte-stable — no hub change.
- capability manifest: 3 non-critical FELHOM_SELFUPDATE probes.
- main.go: updateExec appended to the executor chain; commit-manager wired to
  the report seam + MaybeCommit goroutine after core init.

Tests: Group A (executor happy/sha-mismatch+companion/bad-params/wrapper-fail),
B (agent_update rides the real gate: pinned-key executes, non-pinned +
retarget rejected), C (commit/version-mismatch/no-pending/shutdown), D (opsign).
C2 companion red-proof verified (neutered Go verify → bad binary reaches apply
→ test fails), reverted. Full go test ./... green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-05 15:32:15 +02:00
parent b7cbded429
commit 8033a522cd
16 changed files with 923 additions and 4 deletions
+122
View File
@@ -0,0 +1,122 @@
package selfupdate
import (
"context"
"log/slog"
"time"
)
// Manager owns the post-restart COMMIT half of the A/B update (TASK D1 Scenario D). After a flip,
// the NEW binary boots with a pending marker on disk; once it has run cleanly for the dwell AND
// core init is done, it calls the wrapper's `commit` to clear the marker. A crash before that →
// systemd + the wrapper roll back to .prev (this Manager never rolls back).
type Manager struct {
stateDir string
runningVersion string
dwell time.Duration
runner WrapperRunner
logger *slog.Logger
now func() time.Time // injectable for tests
sleep func(context.Context, time.Duration)
}
// ManagerConfig wires the commit manager.
type ManagerConfig struct {
StateDir string
RunningVersion string // this binary's own version (main.version)
Dwell time.Duration // 0 → 60s
Runner WrapperRunner
Logger *slog.Logger
}
// NewManager builds the commit manager.
func NewManager(cfg ManagerConfig) *Manager {
dwell := cfg.Dwell
if dwell == 0 {
dwell = 60 * time.Second
}
return &Manager{
stateDir: cfg.StateDir,
runningVersion: cfg.RunningVersion,
dwell: dwell,
runner: cfg.Runner,
logger: orDefaultLogger(cfg.Logger),
now: func() time.Time { return time.Now() },
sleep: func(ctx context.Context, d time.Duration) {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
case <-t.C:
}
},
}
}
// PendingStatus is the report-facing snapshot: whether an update is awaiting commit, and (when so)
// the target version. Read synchronously at report-build time — cheap (one stat+parse).
type PendingStatus struct {
Pending bool
Version string
}
// Status returns the current pending-marker state for the host report. A read error is treated as
// "not pending" for the report (the loud path is the commit goroutine's WARNs), never fatal.
func (m *Manager) Status() PendingStatus {
pm, err := readPending(m.stateDir)
if err != nil || pm == nil {
return PendingStatus{}
}
return PendingStatus{Pending: true, Version: pm.NewVersion}
}
// SelfUpdatePending satisfies hub.SelfUpdateReporter (the report seam): pending + awaited version.
func (m *Manager) SelfUpdatePending() (bool, string) {
s := m.Status()
return s.Pending, s.Version
}
// MaybeCommit runs the startup commit decision, blocking for the dwell when a matching update is
// pending. Call it in a goroutine AFTER core init (config parsed, local API up, control loop
// started) — never on the startup critical path. Behaviour (Scenario C5 + D):
// - no pending marker → nothing to do (the common case).
// - pending.new_version == this running version → dwell, then `commit` (clears the marker).
// - pending.new_version != running version → do NOT commit; loud WARN; leave the marker so the
// hub report shows pending=true (a human decides — the box is in a weird state).
func (m *Manager) MaybeCommit(ctx context.Context) {
pm, err := readPending(m.stateDir)
if err != nil {
m.logger.Error("selfupdate: cannot read pending marker — not committing", "err", err)
return
}
if pm == nil {
return // no update in flight
}
if pm.NewVersion != m.runningVersion {
// The running binary is NOT the one this pending marker describes. Do not commit (committing
// would bless a state we can't explain). Leave the marker → the report surfaces pending=true.
m.logger.Warn("selfupdate: pending marker version does not match the running binary — NOT committing (human review)",
"pending_new_version", pm.NewVersion, "running_version", m.runningVersion, "pending_old_version", pm.OldVersion)
return
}
m.logger.Warn("selfupdate: new version running — dwelling before commit",
"version", m.runningVersion, "prev", pm.OldVersion, "dwell", m.dwell)
m.sleep(ctx, m.dwell)
if ctx.Err() != nil {
// Shutting down before the dwell elapsed — leave pending; the next start re-dwells and
// commits. A restart is not a crash (no OnFailure), so this does not trigger rollback.
m.logger.Warn("selfupdate: shutdown before commit dwell elapsed — pending left for next start")
return
}
if m.runner == nil {
m.logger.Error("selfupdate: no wrapper runner — cannot commit (marker left)")
return
}
stdout, stderr, err := m.runner.Run(ctx, wrapperPath, "commit")
if err != nil {
m.logger.Error("selfupdate: commit failed (marker left; will retry next start)", "err", err, "stderr", string(stderr))
return
}
m.logger.Warn("selfupdate: update committed", "version", m.runningVersion, "wrapper", trim(stdout))
}
+117
View File
@@ -0,0 +1,117 @@
package selfupdate
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
)
// writePending drops a pending.json fixture into the staging dir.
func writePending(t *testing.T, stateDir string, m PendingMarker) {
t.Helper()
dir := stagingDir(stateDir)
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(m)
if err := os.WriteFile(filepath.Join(dir, "pending.json"), b, 0o644); err != nil {
t.Fatal(err)
}
}
func newMgr(t *testing.T, stateDir, runningVersion string, wrap WrapperRunner, buf *bytes.Buffer) *Manager {
t.Helper()
var w io.Writer = io.Discard
if buf != nil {
w = buf
}
m := NewManager(ManagerConfig{
StateDir: stateDir,
RunningVersion: runningVersion,
Dwell: time.Hour, // never elapses in the test; we drive it via the sleep seam
Runner: wrap,
Logger: slog.New(slog.NewTextHandler(w, nil)),
})
// Replace the real dwell sleep with an instant one so the test doesn't block.
m.sleep = func(context.Context, time.Duration) {}
return m
}
// Scenario D: pending marker names THIS version → after the dwell, `commit` is called.
func TestManager_CommitsMatchingVersion(t *testing.T) {
stateDir := t.TempDir()
writePending(t, stateDir, PendingMarker{OldVersion: "0.70.0", NewVersion: "0.70.1", SHA256: "ab", AppliedAt: "t"})
wrap := &fakeWrapper{}
m := newMgr(t, stateDir, "0.70.1", wrap, nil)
m.MaybeCommit(context.Background())
if len(wrap.calls) != 1 || wrap.calls[0][1] != "commit" {
t.Fatalf("expected exactly one `commit` call, got %v", wrap.calls)
}
}
// Scenario C5: pending marker version != running version → commit is NEVER called; a loud WARN is
// logged; the marker is LEFT (so the report keeps showing pending=true).
func TestManager_VersionMismatchDoesNotCommit(t *testing.T) {
stateDir := t.TempDir()
writePending(t, stateDir, PendingMarker{OldVersion: "0.70.0", NewVersion: "0.70.5", SHA256: "ab", AppliedAt: "t"})
wrap := &fakeWrapper{}
buf := &bytes.Buffer{}
m := newMgr(t, stateDir, "0.70.1", wrap, buf) // running 0.70.1, marker says 0.70.5
m.MaybeCommit(context.Background())
if len(wrap.calls) != 0 {
t.Errorf("commit called despite version mismatch: %v", wrap.calls)
}
if !bytes.Contains(buf.Bytes(), []byte("NOT committing")) {
t.Errorf("expected a loud WARN; logs:\n%s", buf.String())
}
// Marker must remain so the report still flags pending.
if p, _ := readPending(stateDir); p == nil {
t.Error("pending marker was removed on a version mismatch (report would lose visibility)")
}
// And the report seam reflects it.
if pending, ver := m.SelfUpdatePending(); !pending || ver != "0.70.5" {
t.Errorf("SelfUpdatePending() = %v/%q, want true/0.70.5", pending, ver)
}
}
// No pending marker → nothing happens (the common steady state).
func TestManager_NoPendingNoOp(t *testing.T) {
stateDir := t.TempDir()
wrap := &fakeWrapper{}
m := newMgr(t, stateDir, "0.70.1", wrap, nil)
m.MaybeCommit(context.Background())
if len(wrap.calls) != 0 {
t.Errorf("commit/anything called with no pending marker: %v", wrap.calls)
}
if pending, _ := m.SelfUpdatePending(); pending {
t.Error("SelfUpdatePending() true with no marker")
}
}
// Shutdown before the dwell elapses → no commit, marker left for the next start.
func TestManager_ShutdownBeforeDwellLeavesPending(t *testing.T) {
stateDir := t.TempDir()
writePending(t, stateDir, PendingMarker{NewVersion: "0.70.1"})
wrap := &fakeWrapper{}
m := newMgr(t, stateDir, "0.70.1", wrap, nil)
// A cancelled ctx before the (seam) sleep → MaybeCommit sees ctx.Err() and bails.
ctx, cancel := context.WithCancel(context.Background())
cancel()
m.MaybeCommit(ctx)
if len(wrap.calls) != 0 {
t.Errorf("commit called during shutdown: %v", wrap.calls)
}
if p, _ := readPending(stateDir); p == nil {
t.Error("pending marker removed on shutdown-before-commit")
}
}
+179
View File
@@ -0,0 +1,179 @@
package selfupdate
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"time"
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
)
// WrapperRunner shells the guarded wrapper's verbs via `sudo -n` (satisfied by *proxmox.ExecRunner
// in RunnerSudo mode). Seam so the executor + commit-manager tests never actually invoke sudo.
type WrapperRunner interface {
Run(ctx context.Context, name string, args ...string) (stdout, stderr []byte, err error)
}
// wrapperPath is the fixed guarded-wrapper path (never config-overridable — path-fixedness is the
// security property).
const wrapperPath = "/usr/local/sbin/felhom-selfupdate-guarded"
// updateParams is the verified params of an agent_update op — the operator-pinned target.
type updateParams struct {
Version string `json:"version"`
SHA256 string `json:"sha256"`
}
// Executor is the agent_update signed-op consumer. Given a gate-VERIFIED agent_update op it:
// 1. builds the download URL from config + the signed version,
// 2. downloads to <StateDir>/selfupdate/felhom-agent-<version>,
// 3. verifies the download against the SIGNED sha256 (mismatch → refuse, remove, error),
// 4. hands the staged binary to `felhom-selfupdate-guarded apply <staged> <sha>` via sudo -n.
//
// The wrapper re-verifies the sha as root and performs the A/B flip + detached restart, so this
// process may die shortly after the apply call returns (the S2b detached restart usually lets it
// survive to log). Rollback is never this executor's job — a crash-looping new binary is reverted
// by systemd + the wrapper.
type Executor struct {
urlTemplate string
username string
token string
stateDir string
runner WrapperRunner
httpClient *http.Client
logger *slog.Logger
}
// Config is the executor's dependencies (from config.SelfUpdateConfig + the sudo runner).
type Config struct {
URLTemplate string
Username string
Token string
StateDir string
Runner WrapperRunner
HTTPClient *http.Client // nil → a 5-minute-timeout default
Logger *slog.Logger
}
// NewExecutor builds the agent_update executor.
func NewExecutor(cfg Config) *Executor {
hc := cfg.HTTPClient
if hc == nil {
hc = &http.Client{Timeout: 5 * time.Minute}
}
return &Executor{
urlTemplate: cfg.URLTemplate,
username: cfg.Username,
token: cfg.Token,
stateDir: cfg.StateDir,
runner: cfg.Runner,
httpClient: hc,
logger: orDefaultLogger(cfg.Logger),
}
}
// Execute implements signedjobs.Executor for the agent_update op class.
func (e *Executor) Execute(ctx context.Context, op string, params json.RawMessage) error {
if op != opAgentUpdate {
return signedjobs.ErrNoExecutor // not ours — leave queued for the owning executor
}
var p updateParams
if err := json.Unmarshal(params, &p); err != nil {
return fmt.Errorf("agent_update: bad params: %w", err)
}
if !versionRe.MatchString(p.Version) {
return fmt.Errorf("agent_update: refusing — version %q is not bare semver", p.Version)
}
if !sha256Re.MatchString(p.SHA256) {
return fmt.Errorf("agent_update: refusing — sha256 is not 64 lowercase hex")
}
if e.runner == nil {
return fmt.Errorf("agent_update: no wrapper runner configured")
}
dir := stagingDir(e.stateDir)
if err := os.MkdirAll(dir, 0o750); err != nil {
return fmt.Errorf("agent_update: staging dir: %w", err)
}
staged := filepath.Join(dir, "felhom-agent-"+p.Version)
url := interpolateURL(e.urlTemplate, p.Version)
e.logger.Warn("agent_update: downloading operator-signed binary", "version", p.Version, "url", url, "sha256", p.SHA256)
got, err := e.download(ctx, url, staged)
if err != nil {
_ = os.Remove(staged)
return fmt.Errorf("agent_update: download %s: %w", url, err)
}
// The signed sha is the ONLY integrity root — verify BEFORE anything touches the live binary.
if got != p.SHA256 {
_ = os.Remove(staged)
return fmt.Errorf("agent_update: sha256 mismatch — got %s want %s (refusing; agent untouched)", got, p.SHA256)
}
if err := os.Chmod(staged, 0o755); err != nil {
_ = os.Remove(staged)
return fmt.Errorf("agent_update: chmod staged: %w", err)
}
// Hand off to the root wrapper. It re-verifies the sha, flips A/B, writes the pending marker,
// and schedules the detached restart. After this the new binary starts; the commit is the
// Manager's job once it has dwelled cleanly.
e.logger.Warn("agent_update: handing staged binary to the guarded wrapper", "staged", staged, "version", p.Version)
stdout, stderr, err := e.runner.Run(ctx, wrapperPath, "apply", staged, p.SHA256)
if err != nil {
return fmt.Errorf("agent_update: wrapper apply failed: %w (stderr: %s)", err, string(stderr))
}
e.logger.Warn("agent_update: apply handed off; restart scheduled", "version", p.Version, "wrapper", trim(stdout))
return nil
}
// download streams url → dest (0644, fsync'd) and returns the lowercase-hex sha256 of the bytes.
func (e *Executor) download(ctx context.Context, url, dest string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
if e.username != "" || e.token != "" {
req.SetBasicAuth(e.username, e.token)
}
resp, err := e.httpClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("HTTP %d", resp.StatusCode)
}
f, err := os.OpenFile(dest, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return "", err
}
h := sha256.New()
if _, err := io.Copy(io.MultiWriter(f, h), resp.Body); err != nil {
f.Close()
return "", err
}
if err := f.Sync(); err != nil {
f.Close()
return "", err
}
if err := f.Close(); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
func trim(b []byte) string {
s := string(b)
if len(s) > 200 {
s = s[:200]
}
return s
}
+164
View File
@@ -0,0 +1,164 @@
package selfupdate
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"gitea.dooplex.hu/admin/felhom-agent/internal/signedjobs"
)
// fakeWrapper records the verbs the executor/manager shell out, and returns a configurable error.
type fakeWrapper struct {
mu sync.Mutex
calls [][]string
err error
}
func (f *fakeWrapper) Run(_ context.Context, name string, args ...string) ([]byte, []byte, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, append([]string{name}, args...))
return []byte("ok"), nil, f.err
}
func (f *fakeWrapper) applyCalls() [][]string {
f.mu.Lock()
defer f.mu.Unlock()
var out [][]string
for _, c := range f.calls {
if len(c) >= 2 && c[1] == "apply" {
out = append(out, c)
}
}
return out
}
func sha256Of(b []byte) string {
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
// artifactServer serves `body` at /…/{version}/felhom-agent.
func artifactServer(t *testing.T, body []byte) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(body)
}))
}
func newExec(t *testing.T, srv *httptest.Server, wrap WrapperRunner) (*Executor, string) {
t.Helper()
stateDir := t.TempDir()
e := NewExecutor(Config{
URLTemplate: srv.URL + "/{version}/felhom-agent",
StateDir: stateDir,
Runner: wrap,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
})
return e, stateDir
}
func updateParamsJSON(t *testing.T, version, sha string) json.RawMessage {
t.Helper()
b, _ := json.Marshal(map[string]string{"version": version, "sha256": sha})
return b
}
// Scenario A (executor half): a good download whose sha matches the signed value is staged and
// handed to `apply` with the EXACT staged path + sha.
func TestExecutor_HappyPath(t *testing.T) {
body := []byte("#!/bin/sh\necho v0.70.1\n")
srv := artifactServer(t, body)
defer srv.Close()
wrap := &fakeWrapper{}
e, stateDir := newExec(t, srv, wrap)
sha := sha256Of(body)
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha)); err != nil {
t.Fatalf("execute: %v", err)
}
staged := filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")
got, err := os.ReadFile(staged)
if err != nil || sha256Of(got) != sha {
t.Fatalf("staged binary missing/mismatch: %v", err)
}
calls := wrap.applyCalls()
if len(calls) != 1 {
t.Fatalf("apply invoked %d times, want 1 (%v)", len(calls), wrap.calls)
}
if calls[0][2] != staged || calls[0][3] != sha {
t.Errorf("apply args = %v, want [.. apply %s %s]", calls[0], staged, sha)
}
}
// Scenario C2 + its companion: a download whose sha != the signed value is REFUSED, nothing is
// handed to apply, and the staged file is removed. The companion (dropping the Go-side verify) is
// structural: the sha check IS the code under test — if it were removed, this bad binary would
// reach the apply call (asserted here: applyCalls == 0).
func TestExecutor_ShaMismatchRefused(t *testing.T) {
body := []byte("the REAL published bytes")
srv := artifactServer(t, body)
defer srv.Close()
wrap := &fakeWrapper{}
e, stateDir := newExec(t, srv, wrap)
wrongSha := sha256Of([]byte("what the operator signed for a DIFFERENT binary"))
err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", wrongSha))
if err == nil {
t.Fatal("expected sha-mismatch refusal, got nil")
}
if len(wrap.applyCalls()) != 0 {
t.Error("a sha-mismatched binary REACHED the apply call — the verify gate leaked")
}
if _, statErr := os.Stat(filepath.Join(stateDir, "selfupdate", "felhom-agent-0.70.1")); !os.IsNotExist(statErr) {
t.Error("staged file was left behind after a sha mismatch")
}
}
// Bad params / non-semver version / non-hex sha are refused before any download or apply.
func TestExecutor_BadParamsRefused(t *testing.T) {
wrap := &fakeWrapper{}
e, _ := newExec(t, artifactServer(t, []byte("x")), wrap)
for name, p := range map[string]json.RawMessage{
"non-semver version": updateParamsJSON(t, "latest", sha256Of([]byte("x"))),
"non-hex sha": updateParamsJSON(t, "0.70.1", "NOTHEX"),
"bad json": json.RawMessage(`{`),
} {
if err := e.Execute(context.Background(), "agent_update", p); err == nil {
t.Errorf("%s: expected refusal", name)
}
}
if len(wrap.calls) != 0 {
t.Errorf("wrapper invoked on bad params: %v", wrap.calls)
}
}
// A non-owned op class returns ErrNoExecutor (the chain contract) — never touches anything.
func TestExecutor_NotOurOp(t *testing.T) {
e, _ := newExec(t, artifactServer(t, []byte("x")), &fakeWrapper{})
if err := e.Execute(context.Background(), "storage_wipe", json.RawMessage(`{}`)); !errors.Is(err, signedjobs.ErrNoExecutor) {
t.Errorf("err = %v, want ErrNoExecutor", err)
}
}
// A wrapper apply failure surfaces (the executor reports it — the runner then logs + clears).
func TestExecutor_WrapperFailureSurfaces(t *testing.T) {
body := []byte("good bytes")
srv := artifactServer(t, body)
defer srv.Close()
wrap := &fakeWrapper{err: errors.New("wrapper refused: sha mismatch")}
e, _ := newExec(t, srv, wrap)
if err := e.Execute(context.Background(), "agent_update", updateParamsJSON(t, "0.70.1", sha256Of(body))); err == nil {
t.Fatal("wrapper failure must surface")
}
}
+77
View File
@@ -0,0 +1,77 @@
// 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 <StateDir>/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
}