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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user