Files
felhom-agent/internal/selfupdate/commit_test.go
T
admin 8033a522cd 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
2026-07-05 15:32:15 +02:00

118 lines
3.8 KiB
Go

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")
}
}