//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")) require.NoError(t, s.SetNewer(ctx, f.to, sluskDrift+":3.1", t0)) // 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, sluskDrift+":3.1", got.NewerRef, "newer tag from the duplicate") require.NotNil(t, got.NewerAt) assert.True(t, t0.Equal(*got.NewerAt)) 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.ReasonMergeSelf)}, {"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.ReasonMergeSelf)}, {"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") }