1package appstate_test
2
3import (
4 "context"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/gordon/internal/domain"
11)
12
13// TestStore_RetireAppEndsIncarnation proves removing an app archives its
14// ownership, clears its operational state, and detaches the name so a
15// reapply allocates a new incarnation that cannot inherit the old
16// secrets or volumes.
17func TestStore_RetireAppEndsIncarnation(t *testing.T) {
18 store := newTestStore(t)
19 ctx := context.Background()
20
21 require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{
22 App: "blog", ID: "app-old",
23 Volumes: []domain.AppOwnedVolume{{
24 Name: "data", Service: "web",
25 RuntimeName: "gordon-blog--web--vol--data",
26 State: domain.AppResourceAttached,
27 }},
28 Secrets: []domain.AppOwnedSecret{{
29 Service: "web", Env: "DATABASE_URL", Name: "database-url",
30 Path: "gordon/apps/app-old/blog/web/database-url", State: domain.AppResourceAttached,
31 }},
32 Images: []domain.AppOwnedImage{{
33 Service: "web", Reference: "registry.example.com/blog/web:1.4.2",
34 Digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
35 State: domain.AppResourceAttached,
36 }},
37 }))
38 require.NoError(t, store.SaveActive(ctx, domain.AppActive{
39 App: "blog",
40 Services: map[string]domain.AppEffectiveService{
41 "web": {Container: "c-old", EffectiveRevision: "rev-old"},
42 },
43 }))
44 require.NoError(t, store.SaveIntent(ctx, domain.AppStopIntent{App: "blog", Stopped: true}))
45
46 require.NoError(t, store.RetireApp(ctx, "blog"))
47
48 ownership, err := store.LoadOwnership(ctx, "blog")
49 require.NoError(t, err)
50 assert.Empty(t, ownership.ID, "the name must not retain the old incarnation UUID")
51 assert.Empty(t, ownership.Volumes)
52 assert.Empty(t, ownership.Secrets)
53
54 _, ok, err := store.LoadActive(ctx, "blog")
55 require.NoError(t, err)
56 assert.False(t, ok, "active state must be cleared")
57
58 _, ok, err = store.LoadDesired(ctx, "blog")
59 require.NoError(t, err)
60 assert.False(t, ok, "desired state must be cleared")
61
62 intent, err := store.LoadIntent(ctx, "blog")
63 require.NoError(t, err)
64 assert.False(t, intent.Stopped, "intent must be cleared")
65
66 // Reapply: the first write after retirement allocates a new incarnation.
67 require.NoError(t, store.SaveOwnership(ctx, domain.AppOwnership{App: "blog"}))
68 reused, err := store.LoadOwnership(ctx, "blog")
69 require.NoError(t, err)
70 require.NotEmpty(t, reused.ID)
71 assert.NotEqual(t, "app-old", reused.ID, "a reused name must get a new incarnation")
72
73 // The retired incarnation's resources remain protected (prune reads the
74 // archived record), so they are retained rather than adopted or deleted.
75 snapshot, err := store.ProtectionSnapshot(ctx)
76 require.NoError(t, err)
77 assert.True(t, containsVolumeClaim(snapshot.VolumeClaims, "gordon-blog--web--vol--data", domain.VolumeClaimRetained),
78 "retained volumes must stay protected after retirement")
79 claim, ok := snapshot.ImageClaimFor("registry.example.com/blog/web:1.4.2")
80 if !ok {
81 t.Fatalf("retired image claim missing from %+v", snapshot.ImageClaims)
82 }
83 assert.Equal(t, domain.VolumeClaimReleased, claim.State, "a removed app releases its pinned images")
84}
85
86func TestStore_RetireAppIsAtomicOnEmptyApp(t *testing.T) {
87 store := newTestStore(t)
88 require.NoError(t, store.RetireApp(context.Background(), "never-deployed"))
89
90 ownership, err := store.LoadOwnership(context.Background(), "never-deployed")
91 require.NoError(t, err)
92 assert.Empty(t, ownership.ID)
93}
94
95// TestProtectionSnapshot_OperationDigestRootCarriesRepository proves an
96// unfinished operation's digest root is repository-qualified so prune can
97// walk that manifest's closure.
98func TestProtectionSnapshot_OperationDigestRootCarriesRepository(t *testing.T) {
99 ctx := context.Background()
100 store := newTestStore(t)
101 const manifestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
102
103 require.NoError(t, store.SaveOperation(ctx, domain.AppOperation{
104 Op: "op-open", Kind: "deploy", App: "blog",
105 Steps: []domain.AppOperationStep{{
106 ID: "service.web.replace", State: domain.AppStepPending,
107 Digest: manifestDigest,
108 Image: "registry.example.com/blog/web:1.4.2",
109 }},
110 }))
111
112 snapshot, err := store.ProtectionSnapshot(ctx)
113 require.NoError(t, err)
114
115 var digestRoot *domain.ProtectionRoot
116 for i := range snapshot.Roots {
117 if snapshot.Roots[i].Ref == manifestDigest {
118 digestRoot = &snapshot.Roots[i]
119 }
120 }
121 if digestRoot == nil {
122 t.Fatalf("operation digest root missing from %+v", snapshot.Roots)
123 }
124 assert.Equal(t, "blog/web", digestRoot.Repository)
125}
126
127func containsVolumeClaim(claims []domain.VolumeClaim, name string, state domain.VolumeClaimState) bool {
128 for _, claim := range claims {
129 if claim.Name == name && claim.State == state {
130 return true
131 }
132 }
133 return false
134}