internal/server/api/upgrade_test.go
Ref: Size: 12.3 KiB History
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/a73x/eitri/internal/server/api/types"
"github.com/a73x/eitri/internal/server/registry"
"github.com/a73x/eitri/internal/server/release"
"github.com/a73x/eitri/internal/version"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeRelease is a ReleaseSource test double that always returns the same
// manifest (or ok=false when the manifest hasn't been "fetched").
type fakeRelease struct {
m release.Manifest
ok bool
}
func (f fakeRelease) Latest() (release.Manifest, bool) { return f.m, f.ok }
// fakeUpgrader is an AgentUpgrader test double that captures the last offer.
// age is what PendingAgentUpgrade reports for it, so a test can stand an offer
// for as long as it likes without waiting.
type fakeUpgrader struct {
host, version, url, sha string
age time.Duration
}
func (f *fakeUpgrader) OfferAgentUpgrade(hostID, version, url, sha256 string) {
f.host, f.version, f.url, f.sha = hostID, version, url, sha256
}
func (f *fakeUpgrader) ClearAgentUpgrade(hostID string) {
if f.host == hostID {
f.host, f.version, f.url, f.sha = "", "", "", ""
}
}
func (f *fakeUpgrader) PendingAgentUpgrade(hostID string) (string, time.Duration, bool) {
if f.host != hostID || f.host == "" {
return "", 0, false
}
return f.version, f.age, true
}
// upgradeManifest is the happy-path manifest: a newer release with an
// eitri-agent artifact for linux/amd64 (matching the enrolled test host).
func upgradeManifest() release.Manifest {
return release.Manifest{Version: "v0.0.2", Artifacts: map[string]map[string]release.Artifact{
"eitri-agent": {"linux/amd64": {URL: "https://eitri.sh/dl/v0.0.2/eitri-agent_v0.0.2_linux_amd64.tar.gz", SHA256: "abcd"}},
}}
}
func TestUpgradeAgentEndpoint(t *testing.T) {
t.Run("happy path", func(t *testing.T) {
ts, _, _, reg, a := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
reg.SetAgentVersion(hostID, "v0.0.1")
reg.UpdateReport(hostID, registry.Report{})
a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
up := &fakeUpgrader{}
a.SetAgentUpgrader(up)
resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
require.Equal(t, http.StatusAccepted, resp.StatusCode)
assert.Equal(t, hostID, up.host)
assert.Equal(t, "v0.0.2", up.version)
assert.Equal(t, "https://eitri.sh/dl/v0.0.2/eitri-agent_v0.0.2_linux_amd64.tar.gz", up.url)
assert.Equal(t, "abcd", up.sha)
auditResp := do(t, "GET", ts.URL+"/api/v1/audit", testPAT, nil)
require.Equal(t, http.StatusOK, auditResp.StatusCode)
var rows []types.AuditEvent
require.NoError(t, json.NewDecoder(auditResp.Body).Decode(&rows))
var found bool
for _, row := range rows {
if row.Action == "host.agent.upgrade" {
found = true
assert.Contains(t, string(row.Detail), hostID)
}
}
assert.True(t, found, "expected a host.agent.upgrade audit row")
})
t.Run("release source not wired", func(t *testing.T) {
ts, _, _, reg, a := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
reg.SetAgentVersion(hostID, "v0.0.1")
reg.UpdateReport(hostID, registry.Report{})
a.SetAgentUpgrader(&fakeUpgrader{})
// a.release left nil.
resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
})
t.Run("manifest not fetched", func(t *testing.T) {
ts, _, _, reg, a := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
reg.SetAgentVersion(hostID, "v0.0.1")
reg.UpdateReport(hostID, registry.Report{})
a.SetReleaseSource(fakeRelease{ok: false})
a.SetAgentUpgrader(&fakeUpgrader{})
resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
})
t.Run("unknown host id", func(t *testing.T) {
ts, _, _, _, a := newServer(t)
a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
a.SetAgentUpgrader(&fakeUpgrader{})
resp := do(t, "POST", ts.URL+"/api/v1/hosts/does-not-exist/upgrade-agent", testPAT, nil)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("host offline", func(t *testing.T) {
ts, _, _, _, a := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
// No registry report at all: never connected, so offline.
a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
a.SetAgentUpgrader(&fakeUpgrader{})
resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
assert.Equal(t, http.StatusConflict, resp.StatusCode)
})
t.Run("agent already at latest", func(t *testing.T) {
ts, _, _, reg, a := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
reg.SetAgentVersion(hostID, "v0.0.2")
reg.UpdateReport(hostID, registry.Report{})
a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
a.SetAgentUpgrader(&fakeUpgrader{})
resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
assert.Equal(t, http.StatusConflict, resp.StatusCode)
})
t.Run("no artifact for host os/arch", func(t *testing.T) {
ts, _, _, reg, a := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
reg.SetAgentVersion(hostID, "v0.0.1")
reg.UpdateReport(hostID, registry.Report{})
a.SetReleaseSource(fakeRelease{ok: true, m: release.Manifest{
Version: "v0.0.2",
Artifacts: map[string]map[string]release.Artifact{},
}})
a.SetAgentUpgrader(&fakeUpgrader{})
resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
assert.Equal(t, http.StatusConflict, resp.StatusCode)
})
}
// TestHostResponseAgentUpdateAvailable pins the read-side computation in
// buildHostResponses: an online host whose reported AgentVersion trails the
// latest known release surfaces agent_version and a true
// agent_update_available; with no release source wired (or an unfetched
// manifest) the flag stays false even though the version still trails.
func TestHostResponseAgentUpdateAvailable(t *testing.T) {
t.Run("online + behind + release known -> true", func(t *testing.T) {
ts, _, _, reg, a := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
reg.SetAgentVersion(hostID, "v0.0.1")
reg.UpdateReport(hostID, registry.Report{})
a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
require.Equal(t, http.StatusOK, resp.StatusCode)
items := decodeJSONKeys(t, resp)
require.Len(t, items, 1)
assert.Equal(t, "v0.0.1", items[0]["agent_version"])
assert.Equal(t, true, items[0]["agent_update_available"])
})
t.Run("release source unwired -> false", func(t *testing.T) {
ts, _, _, reg, _ := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
reg.SetAgentVersion(hostID, "v0.0.1")
reg.UpdateReport(hostID, registry.Report{})
// a.release left nil.
resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
require.Equal(t, http.StatusOK, resp.StatusCode)
items := decodeJSONKeys(t, resp)
require.Len(t, items, 1)
assert.Equal(t, "v0.0.1", items[0]["agent_version"])
assert.Equal(t, false, items[0]["agent_update_available"])
})
t.Run("manifest not fetched -> false", func(t *testing.T) {
ts, _, _, reg, a := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
reg.SetAgentVersion(hostID, "v0.0.1")
reg.UpdateReport(hostID, registry.Report{})
a.SetReleaseSource(fakeRelease{ok: false})
resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
require.Equal(t, http.StatusOK, resp.StatusCode)
items := decodeJSONKeys(t, resp)
require.Len(t, items, 1)
assert.Equal(t, false, items[0]["agent_update_available"])
})
}
// TestHostResponsePendingUpgrade pins that an offer is visible while it stands.
// Before this the click was silent: the offer rode the next snapshot and
// nothing said so, which reads exactly like a button that did nothing.
func TestHostResponsePendingUpgrade(t *testing.T) {
ts, _, _, reg, a := newServer(t)
out := enroll(t, ts)
hostID := out["host_id"]
reg.SetAgentVersion(hostID, "v0.0.1")
reg.UpdateReport(hostID, registry.Report{})
a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
up := &fakeUpgrader{}
a.SetAgentUpgrader(up)
hosts := func() map[string]any {
resp := do(t, "GET", ts.URL+"/api/v1/hosts", testPAT, nil)
require.Equal(t, http.StatusOK, resp.StatusCode)
items := decodeJSONKeys(t, resp)
require.Len(t, items, 1)
return items[0]
}
assert.Nil(t, hosts()["pending_upgrade"], "no offer, nothing pending")
resp := do(t, "POST", ts.URL+"/api/v1/hosts/"+hostID+"/upgrade-agent", testPAT, nil)
require.Equal(t, http.StatusAccepted, resp.StatusCode)
up.age = 90 * time.Second
pending, ok := hosts()["pending_upgrade"].(map[string]any)
require.True(t, ok, "an outstanding offer is on the wire")
assert.Equal(t, "v0.0.2", pending["version"])
assert.Equal(t, float64(90), pending["age_s"])
// The agent takes it: the offer converges and the pending state goes away
// on its own, without the console having to decide when to stop showing it.
up.ClearAgentUpgrade(hostID)
assert.Nil(t, hosts()["pending_upgrade"], "a converged offer leaves nothing pending")
}
// TestMarshalSnapshotCarriesVersions pins that the SSE snapshot payload
// itself (not just GET /hosts) carries the server's own build version and the
// latest known release version. marshalSnapshots is called directly (in-package)
// rather than driving the SSE endpoint, per the plan's guidance.
func TestMarshalSnapshotCarriesVersions(t *testing.T) {
_, _, _, _, a := newServer(t)
a.SetReleaseSource(fakeRelease{m: upgradeManifest(), ok: true})
payloads, err := a.marshalSnapshots([]string{testTenant})
require.NoError(t, err)
raw := payloads[testTenant]
require.NotNil(t, raw)
var snap types.StateSnapshot
require.NoError(t, json.Unmarshal(raw, &snap))
assert.Equal(t, version.Version, snap.ServerVersion)
assert.Equal(t, "v0.0.2", snap.LatestVersion)
}
// refuseBelowFloor is the one admission shape every version-floored feature
// shares. judgeOffline is its only parameter: the shipped floors let a silent
// host through (a newer agent may pick the work up), volumes do not (an old
// agent would boot the guest bare and the data lands in the wrong place).
func TestRefuseBelowFloor(t *testing.T) {
ts, _, _, reg, a := newServer(t)
host := enroll(t, ts)
hostID := host["host_id"]
f := release.Feature{Name: "widgets", Since: "v0.0.5"}
refuse := func(judgeOffline bool) (bool, string) {
rec := httptest.NewRecorder()
stop := a.refuseBelowFloor(rec, hostID, f, judgeOffline)
return stop, rec.Body.String()
}
// Silent host: not judged unless asked to be.
stop, _ := refuse(false)
assert.False(t, stop, "a silent host is let through when judgeOffline is false")
stop, body := refuse(true)
assert.True(t, stop, "a silent host is refused when judgeOffline is true")
assert.Contains(t, body, "widgets")
assert.Contains(t, body, "not reporting")
assert.Contains(t, body, "upgrade-agent")
// Online, below floor: refused either way, naming version and floor.
reg.SetAgentVersion(hostID, "v0.0.4")
reg.UpdateReport(hostID, registry.Report{})
stop, body = refuse(false)
assert.True(t, stop)
assert.Contains(t, body, "v0.0.4")
assert.Contains(t, body, "v0.0.5")
assert.Contains(t, body, "widgets")
assert.Contains(t, body, "(v0.0.5). Upgrade", "a feature naming no consequence skips the clause")
// Online, no version: the "reported no agent version" reading.
reg.SetAgentVersion(hostID, "")
reg.UpdateReport(hostID, registry.Report{})
stop, body = refuse(false)
assert.True(t, stop)
assert.Contains(t, body, "has reported no agent version")
// At floor: admitted.
reg.SetAgentVersion(hostID, "v0.0.5")
reg.UpdateReport(hostID, registry.Report{})
stop, _ = refuse(true)
assert.False(t, stop)
// A feature that says what ignoring it costs puts that sentence in the
// refusal, between what is known and the fix. Naming only the floor leaves
// the operator — and the model reading this back out of MCP — to guess why
// it matters.
costly := release.Feature{
Name: "widgets",
Since: "v0.0.9",
Consequence: "the widget lands in the wrong drawer",
}
rec := httptest.NewRecorder()
assert.True(t, a.refuseBelowFloor(rec, hostID, costly, false))
assert.Contains(t, rec.Body.String(),
"widgets (v0.0.9): the widget lands in the wrong drawer. Upgrade that host's agent")
}