Files
package-updater/internal/master/web/drift_test.go
shcizo 919ad91710
Docker Build / docker (push) Successful in 42s
feat: master/agent phase 5 — drift and merge, backend (#10) (#30)
## Summary
- Drift detection: `model.Service.Drifted()` compares desired and running version by digest (tag when the digest is unknown). `services.awaiting_inventory` hides drift between a job result and the next inventory write.
- Poll and webhook jobs are refused for a drifted service; `POST /services/{id}/restore` (manual job to `desired_ref`) and `POST /services/{id}/accept` (running version becomes desired, guarded by `actual` = `Service.RunningRef`).
- Move suggestions (`store.MoveCandidates`, Compose only) and `POST /services/{id}/merge`: the old row keeps its id, mode, `desired_ref` and history and takes the new location; the duplicate is deleted.
- Backend only. The buttons and badges come with #28, on top of the Ledger UI (#18).

Closes #10

## Test plan
- [x] `gofmt -l .`, `go vet ./...`, `go test ./...`
- [x] `go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/`
- [x] `go test -tags integration ./internal/master/store/` against postgres:17-alpine
- [ ] On the server: migration 0005 applies; no drift shown after a normal update
- [ ] On the server: manual `docker compose up` with another tag gives drift; webhook for an auto service is refused
- [ ] On the server: restore and accept via POST; merge after moving a stack

Reviewed-on: #30
2026-10-13 09:04:36 +00:00

298 lines
11 KiB
Go

package web
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/shcizo/package-updater/internal/master/events"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/protocol"
)
const (
driftDesired = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
driftActual = "sha256:2222222222222222222222222222222222222222222222222222222222222222"
)
type acceptCall struct {
id int64
actualRef, actualDigest, desiredRef string
}
func (f *fakeStore) AcceptRunning(_ context.Context, id int64, actualRef, actualDigest, desired string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.acceptCalls = append(f.acceptCalls, acceptCall{id, actualRef, actualDigest, desired})
return f.acceptErr
}
func (f *fakeStore) MoveCandidates(context.Context) ([]model.Move, error) { return f.moves, nil }
func (f *fakeStore) MergeSources(context.Context, int64) ([]model.Service, error) {
return f.sources, nil
}
func (f *fakeStore) MergeService(_ context.Context, from, to int64) error {
f.mu.Lock()
defer f.mu.Unlock()
f.merged = append(f.merged, [2]int64{from, to})
return f.mergeErr
}
func (f *fakeJobs) Restorable(model.Service) error { return f.restorable }
func (f *fakeJobs) RestoreDesired(_ context.Context, serviceID int64, by string) (model.Job, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.requestedBy = by
f.restored = append(f.restored, serviceID)
return f.job, f.err
}
// driftService makes the first fixture service a drifted one: updated to
// 1.4.2 at driftDesired, now running 1.4.2 at driftActual.
func driftService(store *fakeStore) {
store.services[0].DesiredRef = "gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftDesired
store.services[0].ActualDigest = driftActual
}
func postTo(t *testing.T, srv *httptest.Server, path string, form url.Values) (*http.Response, string) {
t.Helper()
form.Set("csrf_token", "csrf")
resp, err := noRedirectClient().PostForm(srv.URL+path, form)
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return resp, string(body)
}
func TestRestoreDesired(t *testing.T) {
tests := []struct {
name string
job model.Job
err error
path string
wantCode int
wantLocation string
wantBody string
}{
{name: "started", job: model.Job{ID: 42}, path: "/services/1/restore",
wantCode: http.StatusSeeOther, wantLocation: "/jobs/42"},
{name: "already running", job: model.Job{ID: 41}, err: model.ErrJobActive, path: "/services/1/restore",
wantCode: http.StatusSeeOther, wantLocation: "/jobs/41"},
{name: "refused", err: &model.NotUpdatableError{Reason: model.ReasonNotDrifted}, path: "/services/1/restore",
wantCode: http.StatusConflict, wantBody: "Cannot restore: runs its desired version."},
{name: "unknown service", err: model.ErrNotFound, path: "/services/1/restore", wantCode: http.StatusNotFound},
{name: "invalid id", path: "/services/x/restore", wantCode: http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv, _, _, _, jobs := newTestEnv(t)
jobs.job, jobs.err = tt.job, tt.err
resp, body := postTo(t, srv, tt.path, url.Values{})
assert.Equal(t, tt.wantCode, resp.StatusCode)
assert.Equal(t, tt.wantLocation, resp.Header.Get("Location"))
assert.Contains(t, body, tt.wantBody)
if tt.wantCode != http.StatusBadRequest {
assert.Equal(t, "alice", jobs.triggeredBy())
}
})
}
}
func TestAcceptRunning_WritesRunningRefAndPublishes(t *testing.T) {
srv, store, _, broker, _ := newTestEnv(t)
driftService(store)
ch, cancel := broker.Subscribe()
defer cancel()
resp, _ := postTo(t, srv, "/services/1/accept", url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}})
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/services/1", resp.Header.Get("Location"))
assert.Equal(t, []acceptCall{{
id: 1, actualRef: "gitea.shcizo.se/shcizo/slusk:1.4.2", actualDigest: driftActual,
desiredRef: "gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual,
}}, store.acceptCalls, "the value written comes from the row, not the form")
select {
case topic := <-ch:
assert.Equal(t, events.Services, topic)
case <-time.After(time.Second):
t.Fatal("no services signal")
}
}
func TestAcceptRunning_ReturnToOverview(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
driftService(store)
resp, _ := postTo(t, srv, "/services/1/accept",
url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}, "return": {"overview"}})
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/", resp.Header.Get("Location"))
}
func TestAcceptRunning_Refusals(t *testing.T) {
tests := []struct {
name string
path string
form url.Values
notDrifted bool
removed bool
acceptErr error
wantCode int
wantBody string
}{
{name: "missing actual", path: "/services/1/accept", form: url.Values{}, wantCode: http.StatusBadRequest},
{name: "stale actual", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.3.0"}},
wantCode: http.StatusConflict, wantBody: "The running version changed; check it again."},
{name: "same ref, other digest", path: "/services/1/accept",
form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftDesired}},
wantCode: http.StatusConflict, wantBody: "The running version changed; check it again."},
{name: "not drifted", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}},
notDrifted: true, wantCode: http.StatusConflict, wantBody: "Cannot accept: the service runs its desired version."},
{name: "changed under our feet", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}},
acceptErr: model.ErrConflict, wantCode: http.StatusConflict, wantBody: "The running version changed; check it again."},
{name: "unknown service", path: "/services/9/accept", form: url.Values{"actual": {"x"}}, wantCode: http.StatusNotFound},
{name: "removed", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}},
removed: true, wantCode: http.StatusNotFound},
{name: "removed meanwhile", path: "/services/1/accept", form: url.Values{"actual": {"gitea.shcizo.se/shcizo/slusk:1.4.2@" + driftActual}},
acceptErr: model.ErrNotFound, wantCode: http.StatusNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
if !tt.notDrifted {
driftService(store)
}
if tt.removed {
removedAt := time.Now()
store.services[0].RemovedAt = &removedAt
}
store.acceptErr = tt.acceptErr
resp, body := postTo(t, srv, tt.path, tt.form)
assert.Equal(t, tt.wantCode, resp.StatusCode)
assert.Contains(t, body, tt.wantBody)
if tt.acceptErr == nil {
assert.Empty(t, store.acceptCalls)
}
})
}
}
func TestMergeService_RedirectsToTheSurvivorAndPublishes(t *testing.T) {
srv, store, _, broker, _ := newTestEnv(t)
ch, cancel := broker.Subscribe()
defer cancel()
resp, _ := postTo(t, srv, "/services/1/merge", url.Values{"from": {"17"}})
assert.Equal(t, http.StatusSeeOther, resp.StatusCode)
assert.Equal(t, "/services/17", resp.Header.Get("Location"))
assert.Equal(t, [][2]int64{{17, 1}}, store.merged)
var got []events.Topic
for len(got) < 2 {
select {
case topic := <-ch:
got = append(got, topic)
case <-time.After(time.Second):
t.Fatalf("signals so far: %v", got)
}
}
assert.ElementsMatch(t, []events.Topic{events.Services, events.Jobs}, got)
}
func TestMergeService_Refusals(t *testing.T) {
tests := []struct {
name string
id, from string
err error
wantCode int
wantBody string
}{
{name: "invalid from", id: "1", from: "x", wantCode: http.StatusBadRequest},
{name: "empty from", id: "1", from: "", wantCode: http.StatusBadRequest},
{name: "refused", id: "1", from: "17", err: &model.MergeRefusedError{Reason: model.ReasonMergeLiveOld},
wantCode: http.StatusConflict, wantBody: "Cannot merge: the old service still exists."},
{name: "job active", id: "1", from: "17", err: model.ErrJobActive,
wantCode: http.StatusConflict, wantBody: "Cannot merge: an update is already in progress for this service."},
{name: "agent revoked", id: "1", from: "17", err: model.ErrRevoked, wantCode: http.StatusConflict, wantBody: "Cannot merge:"},
{name: "not found", id: "1", from: "17", err: model.ErrNotFound, wantCode: http.StatusNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv, store, _, _, _ := newTestEnv(t)
store.mergeErr = tt.err
resp, body := postTo(t, srv, "/services/"+tt.id+"/merge", url.Values{"from": {tt.from}})
assert.Equal(t, tt.wantCode, resp.StatusCode)
assert.Contains(t, body, tt.wantBody)
assert.Empty(t, resp.Header.Get("Location"))
})
}
}
// serviceDetail's data is what the service template reads.
func TestServiceDetail_DriftAndMoveData(t *testing.T) {
removed := time.Now()
live := model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", ActualRef: "slusk:1"}
gone := model.Service{ID: 2, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web", ActualRef: "slusk:1", RemovedAt: &removed}
swarm := model.Service{ID: 3, Kind: protocol.KindSwarm, SwarmService: "gitea", ActualRef: "gitea:1"}
container := model.Service{ID: 4, Kind: protocol.KindContainer, ContainerName: "adhoc", ActualRef: "redis:7"}
self := model.Service{ID: 5, Kind: protocol.KindCompose, ComposeProject: "pu", ComposeService: "agent", ActualRef: "pu:1", Self: true}
move := model.Move{FromID: 2, ToID: 1, FromAgent: "old", ToAgent: "new"}
sources := []model.Service{gone}
tests := []struct {
name string
id int64
restorable error
wantBlocked string
wantFrom *model.Move
wantTo *model.Move
wantSources []model.Service
}{
{name: "live row gets the suggestion and merge choices", id: 1, wantFrom: &move, wantSources: sources},
{name: "removed row gets where it went", id: 2, wantTo: &move},
{name: "swarm service can merge, no suggestion", id: 3, wantSources: sources},
{name: "container cannot merge", id: 4},
{name: "self cannot merge", id: 5},
{name: "restore refusal is passed on", id: 1, restorable: &model.NotUpdatableError{Reason: model.ReasonSelf},
wantBlocked: model.ReasonSelf, wantFrom: &move, wantSources: sources},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
store := &fakeStore{services: []model.Service{live, gone, swarm, container, self},
moves: []model.Move{move}, sources: sources}
jobs := &fakeJobs{restorable: tt.restorable}
s, err := New(store, &fakeHub{}, jobs, events.NewBroker(), Config{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/services/"+strconv.FormatInt(tt.id, 10), nil)
data, err := s.serviceDetail(req, tt.id)
require.NoError(t, err)
assert.Equal(t, tt.wantBlocked, data.RestoreBlocked)
assert.Equal(t, tt.wantFrom, data.MoveFrom)
assert.Equal(t, tt.wantTo, data.MoveTo)
assert.Equal(t, tt.wantSources, data.MergeSources)
})
}
}