feat: master/agent phase 5 — drift and merge, backend (#10) (#30)
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
This commit was merged in pull request #30.
This commit is contained in:
2026-10-13 09:04:36 +00:00
parent 307e46e4c7
commit 919ad91710
21 changed files with 2139 additions and 39 deletions
@@ -0,0 +1,550 @@
//go:build integration
package store
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/shcizo/package-updater/internal/master/model"
"github.com/shcizo/package-updater/internal/protocol"
)
var (
digNew = "sha256:" + strings.Repeat("e", 64)
digOld = "sha256:" + strings.Repeat("f", 64)
sluskDrift = "gitea.shcizo.se/shcizo/slusk"
)
// serviceID returns the ID of the service the agent reports under svc's key.
func serviceID(t *testing.T, s *Store, agentID int64, svc protocol.Service) int64 {
t.Helper()
var id int64
require.NoError(t, s.pool.QueryRow(context.Background(),
`SELECT id FROM services WHERE location_key = $1`, model.LocationKey(agentID, "", svc.Key())).Scan(&id))
return id
}
// finishJob runs a job for the service to success with the deployed ref.
func finishJob(t *testing.T, s *Store, agentID, svcID int64, toRef, deployed string) {
t.Helper()
ctx := context.Background()
nj := newJob(svcID, agentID)
nj.ToRef = toRef
j, err := s.CreateJob(ctx, nj)
require.NoError(t, err)
ok, err := s.FinishJob(ctx, agentID, j.ID, model.JobFinish{Status: model.JobSucceeded, Ref: deployed, StartedAt: t0, FinishedAt: t0})
require.NoError(t, err)
require.True(t, ok)
}
func mustExec(t *testing.T, s *Store, sql string, args ...any) {
t.Helper()
_, err := s.pool.Exec(context.Background(), sql, args...)
require.NoError(t, err)
}
func TestFinishJob_SetsAwaitingInventoryUntilTheNextUpsert(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
agent, svc := jobFixture(t, s, "a")
got, err := s.GetService(ctx, svc)
require.NoError(t, err)
assert.False(t, got.AwaitingInventory)
finishJob(t, s, agent, svc, "r:1", "r:1@"+digNew)
got, err = s.GetService(ctx, svc)
require.NoError(t, err)
assert.True(t, got.AwaitingInventory, "set by the successful job")
assert.False(t, got.Drifted(), "desired is ahead of actual only until the inventory arrives")
require.NoError(t, s.UpsertService(ctx, agent, "", slusk, t0.Add(time.Minute)))
got, err = s.GetService(ctx, svc)
require.NoError(t, err)
assert.False(t, got.AwaitingInventory, "cleared by the agent's upsert")
assert.True(t, got.Drifted(), "the agent still reports the old version")
}
func TestFinishJob_FailedJobDoesNotSetAwaitingInventory(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
agent, svc := jobFixture(t, s, "a")
j, err := s.CreateJob(ctx, newJob(svc, agent))
require.NoError(t, err)
ok, err := s.FinishJob(ctx, agent, j.ID, model.JobFinish{Status: model.JobFailed, Error: "boom", StartedAt: t0, FinishedAt: t0})
require.NoError(t, err)
require.True(t, ok)
got, err := s.GetService(ctx, svc)
require.NoError(t, err)
assert.False(t, got.AwaitingInventory)
}
func TestCreateJob_UnknownServiceIsNotFound(t *testing.T) {
s := openTestStore(t)
agent := newAgent(t, s, "a")
_, err := s.CreateJob(context.Background(), newJob(9999, agent))
assert.ErrorIs(t, err, model.ErrNotFound)
}
// driftedFixture is a service updated to digNew whose agent now reports digD1.
func driftedFixture(t *testing.T, s *Store) (agent, svc int64) {
t.Helper()
agent, svc = jobFixture(t, s, "a")
finishJob(t, s, agent, svc, "r:1", sluskDrift+":1.4.2@"+digNew)
require.NoError(t, s.UpsertService(context.Background(), agent, "", slusk, t0.Add(time.Minute)))
return agent, svc
}
func TestAcceptRunning_WritesTheDesiredRef(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
_, svc := driftedFixture(t, s)
require.NoError(t, s.SetLastTagDigest(ctx, svc, "sha256:ltd"))
require.NoError(t, s.SetAvailable(ctx, svc, model.Available{Ref: sluskDrift + ":1.5.0", Source: model.SourcePoll, At: t0}))
require.NoError(t, s.SetUpdateMode(ctx, svc, model.ModeAuto))
before, err := s.GetService(ctx, svc)
require.NoError(t, err)
require.True(t, before.Drifted())
running := model.RunningRef(before.ActualRef, before.ActualDigest)
require.NoError(t, s.AcceptRunning(ctx, svc, before.ActualRef, before.ActualDigest, running))
got, err := s.GetService(ctx, svc)
require.NoError(t, err)
assert.Equal(t, sluskDrift+":1.4.2@"+digD1, got.DesiredRef)
assert.False(t, got.Drifted())
assert.False(t, got.AwaitingInventory)
assert.Equal(t, "sha256:ltd", got.LastTagDigest, "last_tag_digest untouched")
assert.Equal(t, sluskDrift+":1.5.0", got.AvailableRef, "available suggestion untouched")
assert.Equal(t, model.ModeAuto, got.UpdateMode)
}
func TestAcceptRunning_Refusals(t *testing.T) {
tests := []struct {
name string
prepare func(t *testing.T, s *Store, agent, svc int64) (id int64, actualRef, actualDigest string)
wantError error
}{
{"running version changed", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
return svc, sluskDrift + ":1.4.2", digOld
}, model.ErrConflict},
{"actual ref changed", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
return svc, sluskDrift + ":1.3.0", digD1
}, model.ErrConflict},
{"never updated", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
mustExec(t, s, `UPDATE services SET desired_ref = '' WHERE id = $1`, svc)
return svc, sluskDrift + ":1.4.2", digD1
}, model.ErrConflict},
{"removed", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
require.NoError(t, s.RemoveService(context.Background(), agent, "", slusk.Key(), t0.Add(time.Hour)))
return svc, sluskDrift + ":1.4.2", digD1
}, model.ErrNotFound},
{"unknown", func(t *testing.T, s *Store, agent, svc int64) (int64, string, string) {
return 9999, sluskDrift + ":1.4.2", digD1
}, model.ErrNotFound},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := openTestStore(t)
agent, svc := driftedFixture(t, s)
id, ref, digest := tt.prepare(t, s, agent, svc)
err := s.AcceptRunning(context.Background(), id, ref, digest, "x:1@"+digOld)
assert.ErrorIs(t, err, tt.wantError)
got, gerr := s.GetService(context.Background(), svc)
require.NoError(t, gerr)
assert.NotEqual(t, "x:1@"+digOld, got.DesiredRef, "nothing written")
})
}
}
// moveFixture has agent "a" with slusk removed after it was updated (history,
// auto mode) and agent "b" reporting a new slusk row.
type moveFixture struct {
a, b int64
from, to int64
}
func newMoveFixture(t *testing.T, s *Store) moveFixture {
t.Helper()
ctx := context.Background()
f := moveFixture{a: newAgent(t, s, "a"), b: newAgent(t, s, "b")}
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, []protocol.Service{slusk}, t0))
f.from = serviceID(t, s, f.a, slusk)
finishJob(t, s, f.a, f.from, "r:1", sluskDrift+":1.4.2@"+digNew)
require.NoError(t, s.SetUpdateMode(ctx, f.from, model.ModeAuto))
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, nil, t0.Add(time.Hour)))
require.NoError(t, s.ApplySnapshot(ctx, f.b, worker, []protocol.Service{slusk}, t0.Add(2*time.Hour)))
f.to = serviceID(t, s, f.b, slusk)
return f
}
func TestMoveCandidates(t *testing.T) {
tests := []struct {
name string
mutate func(t *testing.T, s *Store, f moveFixture)
want bool
from string // agent name of the removed row
}{
{"one removed and one live", func(*testing.T, *Store, moveFixture) {}, true, "a"},
{"removed row's agent was revoked", func(t *testing.T, s *Store, f moveFixture) {
_, err := s.RevokeAgent(context.Background(), f.a, t0.Add(3*time.Hour))
require.NoError(t, err)
}, true, "a"},
{"two live targets", func(t *testing.T, s *Store, f moveFixture) {
c := newAgent(t, s, "c")
require.NoError(t, s.ApplySnapshot(context.Background(), c, worker, []protocol.Service{slusk}, t0.Add(3*time.Hour)))
}, false, ""},
{"two removed sources", func(t *testing.T, s *Store, f moveFixture) {
ctx := context.Background()
d := newAgent(t, s, "d")
require.NoError(t, s.ApplySnapshot(ctx, d, worker, []protocol.Service{slusk}, t0))
require.NoError(t, s.ApplySnapshot(ctx, d, worker, nil, t0.Add(time.Hour)))
}, false, ""},
{"target has history", func(t *testing.T, s *Store, f moveFixture) {
finishJob(t, s, f.b, f.to, "r:1", sluskDrift+":1.4.2@"+digNew)
}, false, ""},
{"target has a desired ref", func(t *testing.T, s *Store, f moveFixture) {
mustExec(t, s, `UPDATE services SET desired_ref = 'r:1@x' WHERE id = $1`, f.to)
}, false, ""},
{"source has no history", func(t *testing.T, s *Store, f moveFixture) {
mustExec(t, s, `DELETE FROM update_jobs WHERE service_id = $1`, f.from)
mustExec(t, s, `UPDATE services SET desired_ref = '', update_mode = 'manual' WHERE id = $1`, f.from)
}, false, ""},
{"source only has auto mode", func(t *testing.T, s *Store, f moveFixture) {
mustExec(t, s, `DELETE FROM update_jobs WHERE service_id = $1`, f.from)
mustExec(t, s, `UPDATE services SET desired_ref = '' WHERE id = $1`, f.from)
}, true, "a"},
{"source only has jobs", func(t *testing.T, s *Store, f moveFixture) {
mustExec(t, s, `UPDATE services SET desired_ref = '', update_mode = 'manual' WHERE id = $1`, f.from)
}, true, "a"},
{"target is older than the source", func(t *testing.T, s *Store, f moveFixture) {
mustExec(t, s, `UPDATE services SET first_seen_at = $2 WHERE id = $1`, f.to, t0.Add(-time.Hour))
}, false, ""},
{"plain containers", func(t *testing.T, s *Store, f moveFixture) {
mustExec(t, s, `UPDATE services SET kind = 'container' WHERE id IN ($1, $2)`, f.from, f.to)
}, false, ""},
{"swarm services", func(t *testing.T, s *Store, f moveFixture) {
mustExec(t, s, `UPDATE services SET kind = 'swarm' WHERE id IN ($1, $2)`, f.from, f.to)
}, false, ""},
{"the updater itself (target)", func(t *testing.T, s *Store, f moveFixture) {
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.to)
}, false, ""},
{"the updater itself (source)", func(t *testing.T, s *Store, f moveFixture) {
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.from)
}, false, ""},
{"live row also removed", func(t *testing.T, s *Store, f moveFixture) {
require.NoError(t, s.RemoveService(context.Background(), f.b, "", slusk.Key(), t0.Add(3*time.Hour)))
}, false, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := openTestStore(t)
f := newMoveFixture(t, s)
tt.mutate(t, s, f)
got, err := s.MoveCandidates(context.Background())
require.NoError(t, err)
if !tt.want {
assert.Empty(t, got)
return
}
assert.Equal(t, []model.Move{{FromID: f.from, ToID: f.to, FromAgent: tt.from, ToAgent: "b"}}, got)
})
}
}
func TestMergeService_MovesHistoryModeAndDesiredToTheNewLocation(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
f := newMoveFixture(t, s)
finishJob(t, s, f.b, f.to, "r:2", sluskDrift+":2.0.0@"+digOld)
require.NoError(t, s.SetLastTagDigest(ctx, f.to, "sha256:fromto"))
require.NoError(t, s.SetAvailable(ctx, f.to, model.Available{Ref: sluskDrift + ":3", Digest: digNew, Source: model.SourcePoll, At: t0}))
require.NoError(t, s.SetPollError(ctx, []int64{f.to}, "registry down"))
// A job on the old row is finished, and awaiting_inventory is set on both.
before, err := s.GetService(ctx, f.from)
require.NoError(t, err)
require.NoError(t, s.MergeService(ctx, f.from, f.to))
got, err := s.GetService(ctx, f.from)
require.NoError(t, err)
assert.Equal(t, f.from, got.ID, "the old id survives")
assert.Nil(t, got.RemovedAt)
assert.False(t, got.AwaitingInventory)
assert.Equal(t, "b", got.AgentName)
assert.Equal(t, model.LocationKey(f.b, "", slusk.Key()), got.LocationKey)
assert.Equal(t, before.FirstSeenAt, got.FirstSeenAt, "first_seen_at kept")
assert.Equal(t, model.ModeAuto, got.UpdateMode, "mode kept")
assert.Equal(t, before.DesiredRef, got.DesiredRef, "desired ref kept")
assert.Equal(t, slusk.Image, got.ActualRef, "inventory from the duplicate")
assert.Equal(t, digD1, got.ActualDigest)
assert.Equal(t, "running", got.Status)
assert.Equal(t, sluskDrift+":3", got.AvailableRef)
assert.Equal(t, digNew, got.AvailableDigest)
assert.Equal(t, "registry down", got.LastPollError)
assert.Equal(t, before.LastTagDigest, got.LastTagDigest, "an existing last_tag_digest wins")
_, err = s.GetService(ctx, f.to)
assert.ErrorIs(t, err, model.ErrNotFound, "the duplicate is deleted")
jobs, err := s.ListJobs(ctx, model.JobFilter{ServiceID: f.from})
require.NoError(t, err)
assert.Len(t, jobs, 2, "the duplicate's job moved to the survivor")
live, err := s.ListServices(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"slusk/web"}, names(live))
}
func TestMergeService_TakesLastTagDigestOfTheDuplicateWhenTheOldRowHasNone(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
f := newMoveFixture(t, s)
mustExec(t, s, `UPDATE services SET last_tag_digest = '' WHERE id = $1`, f.from)
require.NoError(t, s.SetLastTagDigest(ctx, f.to, "sha256:fromto"))
require.NoError(t, s.MergeService(ctx, f.from, f.to))
got, err := s.GetService(ctx, f.from)
require.NoError(t, err)
assert.Equal(t, "sha256:fromto", got.LastTagDigest)
}
func TestMergeService_ManualMergeOfAnyRemovedServiceOfTheSameKind(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
f := newMoveFixture(t, s)
other := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "renamed", ComposeService: "app", Image: "x/y:1", Status: "running"}
require.NoError(t, s.UpsertService(ctx, f.b, "", other, t0.Add(3*time.Hour)))
target := serviceID(t, s, f.b, other)
require.NoError(t, s.MergeService(ctx, f.from, target))
got, err := s.GetService(ctx, f.from)
require.NoError(t, err)
assert.Equal(t, "renamed/app", got.DisplayName(), "names may differ")
}
func TestMergeService_SameAgentIsAllowed(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
f := newMoveFixture(t, s)
other := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "renamed", ComposeService: "app", Image: "x/y:1", Status: "running"}
require.NoError(t, s.UpsertService(ctx, f.a, "", other, t0.Add(3*time.Hour)))
require.NoError(t, s.MergeService(ctx, f.from, serviceID(t, s, f.a, other)))
got, err := s.GetService(ctx, f.from)
require.NoError(t, err)
assert.Equal(t, "a", got.AgentName)
}
func TestMergeService_Swarm(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
m1 := newAgent(t, s, "m1")
require.NoError(t, s.ApplySnapshot(ctx, m1, manager, []protocol.Service{gitea}, t0))
oldID := serviceIDByName(t, s, "gitea_gitea")
require.NoError(t, s.ApplySnapshot(ctx, m1, manager, nil, t0.Add(time.Hour)))
renamed := gitea
renamed.SwarmService = "gitea2_gitea"
require.NoError(t, s.ApplySnapshot(ctx, m1, manager, []protocol.Service{renamed}, t0.Add(2*time.Hour)))
newID := serviceIDByName(t, s, "gitea2_gitea")
require.NoError(t, s.MergeService(ctx, oldID, newID))
got, err := s.GetService(ctx, oldID)
require.NoError(t, err)
assert.Equal(t, "gitea2_gitea", got.SwarmService)
assert.Nil(t, got.RemovedAt)
}
func serviceIDByName(t *testing.T, s *Store, swarmService string) int64 {
t.Helper()
var id int64
require.NoError(t, s.pool.QueryRow(context.Background(),
`SELECT id FROM services WHERE swarm_service = $1`, swarmService).Scan(&id))
return id
}
func TestMergeService_Refusals(t *testing.T) {
refused := func(reason string) func(*testing.T, error) {
return func(t *testing.T, err error) {
var mr *model.MergeRefusedError
require.True(t, errors.As(err, &mr), "got %v", err)
assert.Equal(t, reason, mr.Reason)
}
}
is := func(want error) func(*testing.T, error) {
return func(t *testing.T, err error) { assert.ErrorIs(t, err, want) }
}
tests := []struct {
name string
run func(t *testing.T, s *Store, f moveFixture) error
assert func(*testing.T, error)
}{
{"into itself", func(t *testing.T, s *Store, f moveFixture) error {
return s.MergeService(context.Background(), f.to, f.to)
}, refused(model.ReasonMergeSame)},
{"the old service still exists", func(t *testing.T, s *Store, f moveFixture) error {
mustExec(t, s, `UPDATE services SET removed_at = NULL WHERE id = $1`, f.from)
return s.MergeService(context.Background(), f.from, f.to)
}, refused(model.ReasonMergeLiveOld)},
{"the new service was removed", func(t *testing.T, s *Store, f moveFixture) error {
require.NoError(t, s.RemoveService(context.Background(), f.b, "", slusk.Key(), t0.Add(3*time.Hour)))
return s.MergeService(context.Background(), f.from, f.to)
}, refused(model.ReasonMergeGoneNew)},
{"different kinds", func(t *testing.T, s *Store, f moveFixture) error {
mustExec(t, s, `UPDATE services SET kind = 'swarm' WHERE id = $1`, f.to)
return s.MergeService(context.Background(), f.from, f.to)
}, refused(model.ReasonMergeKind)},
{"plain containers", func(t *testing.T, s *Store, f moveFixture) error {
mustExec(t, s, `UPDATE services SET kind = 'container' WHERE id IN ($1, $2)`, f.from, f.to)
return s.MergeService(context.Background(), f.from, f.to)
}, refused(model.ReasonMergeContainer)},
{"the updater itself (new)", func(t *testing.T, s *Store, f moveFixture) error {
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.to)
return s.MergeService(context.Background(), f.from, f.to)
}, refused(model.ReasonSelf)},
{"the updater itself (old)", func(t *testing.T, s *Store, f moveFixture) error {
mustExec(t, s, `UPDATE services SET self = true WHERE id = $1`, f.from)
return s.MergeService(context.Background(), f.from, f.to)
}, refused(model.ReasonSelf)},
{"active job on the old service", func(t *testing.T, s *Store, f moveFixture) error {
_, err := s.CreateJob(context.Background(), newJob(f.from, f.a))
require.NoError(t, err)
return s.MergeService(context.Background(), f.from, f.to)
}, is(model.ErrJobActive)},
{"active job on the new service", func(t *testing.T, s *Store, f moveFixture) error {
_, err := s.CreateJob(context.Background(), newJob(f.to, f.b))
require.NoError(t, err)
return s.MergeService(context.Background(), f.from, f.to)
}, is(model.ErrJobActive)},
{"unknown old service", func(t *testing.T, s *Store, f moveFixture) error {
return s.MergeService(context.Background(), 9999, f.to)
}, is(model.ErrNotFound)},
{"unknown new service", func(t *testing.T, s *Store, f moveFixture) error {
return s.MergeService(context.Background(), f.from, 9999)
}, is(model.ErrNotFound)},
{"the new service's agent is revoked", func(t *testing.T, s *Store, f moveFixture) error {
_, err := s.RevokeAgent(context.Background(), f.b, t0.Add(3*time.Hour))
require.NoError(t, err)
return s.MergeService(context.Background(), f.from, f.to)
}, is(model.ErrRevoked)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := openTestStore(t)
f := newMoveFixture(t, s)
var rowsBefore int
require.NoError(t, s.pool.QueryRow(context.Background(), `SELECT count(*) FROM services`).Scan(&rowsBefore))
err := tt.run(t, s, f)
tt.assert(t, err)
var rowsAfter int
require.NoError(t, s.pool.QueryRow(context.Background(), `SELECT count(*) FROM services`).Scan(&rowsAfter))
assert.Equal(t, rowsBefore, rowsAfter, "nothing is deleted when the merge is refused")
})
}
}
func TestMergeService_InventoryAfterTheMerge(t *testing.T) {
setup := func(t *testing.T) (*Store, moveFixture) {
s := openTestStore(t)
f := newMoveFixture(t, s)
require.NoError(t, s.MergeService(context.Background(), f.from, f.to))
return s, f
}
t.Run("an upsert for the new location updates the survivor", func(t *testing.T) {
s, f := setup(t)
ctx := context.Background()
moved := slusk
moved.Digest = digOld
require.NoError(t, s.UpsertService(ctx, f.b, "", moved, t0.Add(4*time.Hour)))
live, err := s.ListServices(ctx)
require.NoError(t, err)
require.Len(t, live, 1)
assert.Equal(t, f.from, live[0].ID)
assert.Equal(t, digOld, live[0].ActualDigest)
})
t.Run("a snapshot keeps the survivor", func(t *testing.T) {
s, f := setup(t)
ctx := context.Background()
require.NoError(t, s.ApplySnapshot(ctx, f.b, worker, []protocol.Service{slusk}, t0.Add(4*time.Hour)))
got, err := s.GetService(ctx, f.from)
require.NoError(t, err)
assert.Nil(t, got.RemovedAt)
live, err := s.ListServices(ctx)
require.NoError(t, err)
assert.Len(t, live, 1)
})
t.Run("the old agent returning with the stack creates a new row", func(t *testing.T) {
s, f := setup(t)
ctx := context.Background()
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, []protocol.Service{slusk}, t0.Add(4*time.Hour)))
returned := serviceID(t, s, f.a, slusk)
assert.NotEqual(t, f.from, returned)
got, err := s.GetService(ctx, returned)
require.NoError(t, err)
assert.Equal(t, model.ModeManual, got.UpdateMode)
assert.Empty(t, got.DesiredRef)
live, err := s.ListServices(ctx)
require.NoError(t, err)
assert.Len(t, live, 2)
})
}
func TestMergeSources(t *testing.T) {
s := openTestStore(t)
ctx := context.Background()
f := newMoveFixture(t, s)
// A removed service with another name, removed later than the matching one.
other := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "renamed", ComposeService: "app", Image: "x/y:1", Status: "running"}
require.NoError(t, s.UpsertService(ctx, f.a, "", other, t0))
require.NoError(t, s.RemoveService(ctx, f.a, "", other.Key(), t0.Add(5*time.Hour)))
otherID := serviceID(t, s, f.a, other)
// Other kinds, the updater itself and a live service never show up.
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, []protocol.Service{adhoc}, t0.Add(6*time.Hour)))
require.NoError(t, s.ApplySnapshot(ctx, f.a, worker, nil, t0.Add(7*time.Hour)))
selfSvc := protocol.Service{Kind: protocol.KindCompose, ComposeProject: "pu", ComposeService: "agent", Image: "pu:1", Status: "running", Self: true}
require.NoError(t, s.UpsertService(ctx, f.a, "", selfSvc, t0))
require.NoError(t, s.RemoveService(ctx, f.a, "", selfSvc.Key(), t0.Add(8*time.Hour)))
got, err := s.MergeSources(ctx, f.to)
require.NoError(t, err)
var ids []int64
for _, m := range got {
ids = append(ids, m.ID)
}
assert.Equal(t, []int64{f.from, otherID}, ids, "same name first, then the most recently removed")
assert.Equal(t, "a", got[0].AgentName)
_, err = s.MergeSources(ctx, 9999)
assert.ErrorIs(t, err, model.ErrNotFound)
containerID := serviceID(t, s, f.a, adhoc)
got, err = s.MergeSources(ctx, containerID)
require.NoError(t, err)
assert.Empty(t, got, "plain containers cannot be merged")
}