Docker Build / docker (push) Successful in 42s
## 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
156 lines
5.2 KiB
Go
156 lines
5.2 KiB
Go
package model
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
|
|
"github.com/shcizo/package-updater/internal/protocol"
|
|
)
|
|
|
|
func TestLocationKey(t *testing.T) {
|
|
cases := []struct {
|
|
name, agentKey, want string
|
|
}{
|
|
{"compose is per agent", "compose|slusk|web", "compose|7|slusk|web"},
|
|
{"container is per agent", "container|adhoc", "container|7|adhoc"},
|
|
{"swarm is per cluster", "swarm|gitea_gitea", "swarm|cluster-1|gitea_gitea"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
assert.Equal(t, c.want, LocationKey(7, "cluster-1", c.agentKey))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDisplayName(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
svc Service
|
|
want string
|
|
}{
|
|
{"compose", Service{Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web"}, "slusk/web"},
|
|
{"swarm", Service{Kind: protocol.KindSwarm, SwarmService: "gitea_gitea"}, "gitea_gitea"},
|
|
{"container", Service{Kind: protocol.KindContainer, ContainerName: "adhoc"}, "adhoc"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
assert.Equal(t, c.want, c.svc.DisplayName())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImageVersionRef(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
v ImageVersion
|
|
want string
|
|
}{
|
|
{"tag and digest", ImageVersion{Repo: "r/x", Tag: "1", Digest: "sha256:a"}, "r/x:1@sha256:a"},
|
|
{"tag only", ImageVersion{Repo: "r/x", Tag: "1"}, "r/x:1"},
|
|
{"digest only", ImageVersion{Repo: "r/x", Digest: "sha256:a"}, "r/x@sha256:a"},
|
|
{"registry port", ImageVersion{Repo: "localhost:5000/x", Tag: "1"}, "localhost:5000/x:1"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
assert.Equal(t, c.want, c.v.Ref())
|
|
})
|
|
}
|
|
}
|
|
|
|
const (
|
|
dA = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
dB = "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
)
|
|
|
|
func TestRunsRef(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
desired, actualRef, actualDig string
|
|
want bool
|
|
}{
|
|
{"same string", "repo:v1@" + dA, "repo:v1@" + dA, "", true},
|
|
{"tag and digest, actual reports it separately", "repo:tag@" + dA, "repo:tag", dA, true},
|
|
{"bare image reports latest digest", "postgres:latest@" + dA, "postgres", dA, true},
|
|
{"tagless actual with the same digest", "repo:tag@" + dA, "repo@" + dA, "", true},
|
|
{"same digest under another tag", "repo:v1@" + dA, "repo:v2@" + dA, "", true},
|
|
{"same digest, actual tag differs, digest reported", "repo:v1@" + dA, "repo:v2", dA, true},
|
|
{"same tag, actual digest unknown", "repo:tag@" + dA, "repo:tag", "", true},
|
|
{"desired without digest, same tag", "repo:tag", "repo:tag@" + dA, "", true},
|
|
{"bare desired, latest actual", "repo", "repo:latest", "", true},
|
|
{"registry with port", "localhost:5000/foo:v1@" + dA, "localhost:5000/foo:v1", dA, true},
|
|
{"registry with port, other digest", "localhost:5000/foo:v1@" + dA, "localhost:5000/foo:v1", dB, false},
|
|
{"same tag, other digest", "repo:tag@" + dA, "repo:tag", dB, false},
|
|
{"other digest in the ref", "repo:tag@" + dA, "repo:tag@" + dB, "", false},
|
|
{"other tag, digest unknown", "repo:v1@" + dA, "repo:v2", "", false},
|
|
{"tagless desired, tagged actual, digest unknown", "repo@" + dA, "repo:v1", "", false},
|
|
{"other repo", "repo:tag@" + dA, "other:tag", dA, false},
|
|
{"no docker.io normalisation", "nginx:1@" + dA, "docker.io/library/nginx:1", dA, false},
|
|
{"case-sensitive repo", "Repo:1@" + dA, "repo:1", dA, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
assert.Equal(t, c.want, RunsRef(c.desired, c.actualRef, c.actualDig))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDrifted(t *testing.T) {
|
|
now := time.Now()
|
|
drifted := Service{DesiredRef: "repo:tag@" + dA, ActualRef: "repo:tag", ActualDigest: dB}
|
|
cases := []struct {
|
|
name string
|
|
mutate func(*Service)
|
|
want bool
|
|
}{
|
|
{"runs another digest", func(*Service) {}, true},
|
|
{"never updated", func(s *Service) { s.DesiredRef = "" }, false},
|
|
{"removed", func(s *Service) { s.RemovedAt = &now }, false},
|
|
{"awaiting inventory", func(s *Service) { s.AwaitingInventory = true }, false},
|
|
{"runs the desired digest", func(s *Service) { s.ActualDigest = dA }, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
svc := drifted
|
|
c.mutate(&svc)
|
|
assert.Equal(t, c.want, svc.Drifted())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunningRef(t *testing.T) {
|
|
cases := []struct {
|
|
name, actualRef, actualDigest, want string
|
|
}{
|
|
{"ref with digest is kept", "repo:v1@" + dB, dA, "repo:v1@" + dB},
|
|
{"tag plus reported digest", "repo:v1", dA, "repo:v1@" + dA},
|
|
{"bare image becomes latest", "postgres", dA, "postgres:latest@" + dA},
|
|
{"registry with port", "localhost:5000/foo:v1", dA, "localhost:5000/foo:v1@" + dA},
|
|
{"no digest known", "repo:v1", "", "repo:v1"},
|
|
{"malformed digest", "repo:v1", "sha256:abc", "repo:v1"},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
assert.Equal(t, c.want, RunningRef(c.actualRef, c.actualDigest))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestJob_Active(t *testing.T) {
|
|
cases := []struct {
|
|
status JobStatus
|
|
want bool
|
|
}{
|
|
{JobQueued, true},
|
|
{JobRunning, true},
|
|
{JobSucceeded, false},
|
|
{JobFailed, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(string(c.status), func(t *testing.T) {
|
|
assert.Equal(t, c.want, Job{Status: c.status}.Active())
|
|
})
|
|
}
|
|
}
|