carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "crypto/ed25519"
6 "os"
7 "path/filepath"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/registry"
12 "github.com/carlosframework/platform/internal/release"
13)
14
15// stampKey writes key into the DirStore and pins its mtime, versions_test's
16// modStamp recipe (that helper is package release; this is the host-side twin).
17func stampKey(t *testing.T, s *release.DirStore, dir, key string, mod time.Time) {
18 t.Helper()
19 if _, err := s.Put(context.Background(), key, []byte(`x`), ""); err != nil {
20 t.Fatal(err)
21 }
22 if err := os.Chtimes(filepath.Join(dir, filepath.FromSlash(key)), mod, mod); err != nil {
23 t.Fatal(err)
24 }
25}
26
27func retentionKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) {
28 t.Helper()
29 seedB64, pubB64, err := release.GenerateKey()
30 if err != nil {
31 t.Fatal(err)
32 }
33 priv, err := release.ParseSigningKey(seedB64, "test")
34 if err != nil {
35 t.Fatal(err)
36 }
37 pub, err := release.ParsePublicKey(pubB64, "test")
38 if err != nil {
39 t.Fatal(err)
40 }
41 return priv, pub
42}
43
44func TestRetentionPassPrunesEligibleAndProtectsBoxPins(t *testing.T) {
45 t.Parallel()
46 ctx := context.Background()
47 dir := t.TempDir()
48 s := release.NewDirStore(dir)
49 now := time.Now()
50 base := now.Add(-30 * 24 * time.Hour)
51 vname := func(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) }
52 for i := 1; i <= 25; i++ {
53 stampKey(t, s, dir, release.ManifestKey("bab", "kass", vname(i)), base.Add(time.Duration(i)*time.Hour))
54 stampKey(t, s, dir, release.ArtifactKey("bab", "kass", vname(i), "linux-arm64"), base.Add(time.Duration(i)*time.Hour))
55 }
56 _, pub := retentionKeys(t)
57 if err := release.SetRetention(ctx, s, "bab", "kass", 20, "test", now); err != nil {
58 t.Fatal(err)
59 }
60 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
61 if err != nil {
62 t.Fatal(err)
63 }
64 defer reg.Close()
65 // This box runs kass at v03 (previous v02): both are pins, even though
66 // both rank outside keep-20.
67 if err := reg.Upsert(registry.Route{
68 Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab",
69 Version: "v03", PrevVersion: "v02", Status: "ready",
70 }); err != nil {
71 t.Fatal(err)
72 }
73
74 if err := retentionPass(ctx, s, &boxReports{status: release.NewDirStore(t.TempDir())}, pub, reg); err != nil {
75 t.Fatal(err)
76 }
77
78 // keep-20 protects v06..v25; pins protect v02, v03 → v01, v04, v05 gone.
79 for _, gone := range []string{"v01", "v04", "v05"} {
80 if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", gone)); err == nil {
81 t.Fatalf("%s still present, want pruned", gone)
82 }
83 }
84 for _, kept := range []string{"v02", "v03", "v06", "v25"} {
85 if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", kept)); err != nil {
86 t.Fatalf("%s pruned, want kept: %v", kept, err)
87 }
88 }
89}
90
91// TestRetentionPassResolvesEmptyAccountRoutesViaRouteAccount covers the
92// review finding: a route row with an EMPTY Account column (unstamped by
93// the domains pass) must still resolve to release.OpsAccount ("bab"), the
94// same resolution every other box-side release path uses
95// (release.RouteAccount, domains/reconcile.go's resolveRouteAccount) —
96// never be silently skipped, and never key its pins under "".
97func TestRetentionPassResolvesEmptyAccountRoutesViaRouteAccount(t *testing.T) {
98 ctx := context.Background()
99 dir := t.TempDir()
100 s := release.NewDirStore(dir)
101 now := time.Now()
102 base := now.Add(-30 * 24 * time.Hour)
103 vname := func(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) }
104 for i := 1; i <= 25; i++ {
105 stampKey(t, s, dir, release.ManifestKey(release.OpsAccount, "kass", vname(i)), base.Add(time.Duration(i)*time.Hour))
106 }
107 _, pub := retentionKeys(t)
108 if err := release.SetRetention(ctx, s, release.OpsAccount, "kass", 20, "test", now); err != nil {
109 t.Fatal(err)
110 }
111 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
112 if err != nil {
113 t.Fatal(err)
114 }
115 defer reg.Close()
116 // Account column left empty — the state every route is in until the
117 // domains pass stamps it (reconcile.go: "A route with an empty account
118 // column resolves to ops until 1b writes the account the BUCKET
119 // asserts"). v03/v02 are this box's pins, both outside keep-20.
120 if err := reg.Upsert(registry.Route{
121 Host: "kass.example.com", Kind: "instance", App: "kass",
122 Version: "v03", PrevVersion: "v02", Status: "ready",
123 }); err != nil {
124 t.Fatal(err)
125 }
126
127 if err := retentionPass(ctx, s, &boxReports{status: release.NewDirStore(t.TempDir())}, pub, reg); err != nil {
128 t.Fatal(err)
129 }
130
131 // The bab/kass policy must have been enforced — an empty Account
132 // column must not cause the app to be silently skipped.
133 if _, _, err := s.Get(ctx, release.ManifestKey(release.OpsAccount, "kass", "v01")); err == nil {
134 t.Fatalf("v01 still present, want pruned (empty-account route was not resolved to %s)", release.OpsAccount)
135 }
136 if _, _, err := s.Get(ctx, release.ManifestKey(release.OpsAccount, "kass", "v04")); err == nil {
137 t.Fatalf("v04 still present, want pruned")
138 }
139 // v02/v03 are the empty-account route's Version/PrevVersion pins —
140 // must survive despite ranking outside keep-20.
141 for _, kept := range []string{"v02", "v03"} {
142 if _, _, err := s.Get(ctx, release.ManifestKey(release.OpsAccount, "kass", kept)); err != nil {
143 t.Fatalf("%s pruned, want kept as an empty-account route's pin: %v", kept, err)
144 }
145 }
146}
147
148// The good build is the fallback a planned wake boots when the new build
149// fails (zero-downtime spec §8.5), so retention keeps it even when no
150// route installs it as Version or PrevVersion any more (zdd-readiness
151// Task 13, Astra R1 T12). Pruned, the fallback's artifact is gone exactly
152// when a failed release needs it.
153func TestRetentionPassKeepsTheGoodBuild(t *testing.T) {
154 t.Parallel()
155 ctx := context.Background()
156 dir := t.TempDir()
157 s := release.NewDirStore(dir)
158 now := time.Now()
159 base := now.Add(-30 * 24 * time.Hour)
160 vname := func(i int) string { return "v" + string(rune('0'+i/10)) + string(rune('0'+i%10)) }
161 for i := 1; i <= 25; i++ {
162 stampKey(t, s, dir, release.ManifestKey("bab", "kass", vname(i)), base.Add(time.Duration(i)*time.Hour))
163 stampKey(t, s, dir, release.ArtifactKey("bab", "kass", vname(i), "linux-arm64"), base.Add(time.Duration(i)*time.Hour))
164 }
165 _, pub := retentionKeys(t)
166 if err := release.SetRetention(ctx, s, "bab", "kass", 20, "test", now); err != nil {
167 t.Fatal(err)
168 }
169 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
170 if err != nil {
171 t.Fatal(err)
172 }
173 defer reg.Close()
174 // Installed v03 over v02, but the last build that served healthily is
175 // v01: all three rank outside keep-20.
176 if err := reg.Upsert(registry.Route{
177 Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab",
178 Version: "v03", PrevVersion: "v02", GoodVersion: "v01", Status: "ready",
179 }); err != nil {
180 t.Fatal(err)
181 }
182
183 if err := retentionPass(ctx, s, &boxReports{status: release.NewDirStore(t.TempDir())}, pub, reg); err != nil {
184 t.Fatal(err)
185 }
186
187 for _, kept := range []string{"v01", "v02", "v03"} {
188 if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", kept)); err != nil {
189 t.Fatalf("%s pruned, want kept as a pin: %v", kept, err)
190 }
191 }
192 if _, _, err := s.Get(ctx, release.ArtifactKey("bab", "kass", "v01", "linux-arm64")); err != nil {
193 t.Fatalf("the good build's artifact was pruned: %v", err)
194 }
195 for _, gone := range []string{"v04", "v05"} {
196 if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", gone)); err == nil {
197 t.Fatalf("%s still present, want pruned", gone)
198 }
199 }
200}
201
202func TestRetentionPassSkipsAppsWithoutAPolicy(t *testing.T) {
203 ctx := context.Background()
204 dir := t.TempDir()
205 s := release.NewDirStore(dir)
206 base := time.Now().Add(-30 * 24 * time.Hour)
207 for i := 1; i <= 25; i++ {
208 v := "v" + string(rune('0'+i/10)) + string(rune('0'+i%10))
209 stampKey(t, s, dir, release.ManifestKey("bab", "kass", v), base.Add(time.Duration(i)*time.Hour))
210 }
211 _, pub := retentionKeys(t)
212 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
213 if err != nil {
214 t.Fatal(err)
215 }
216 defer reg.Close()
217 if err := reg.Upsert(registry.Route{
218 Host: "kass.example.com", Kind: "instance", App: "kass", Account: "bab", Status: "ready",
219 }); err != nil {
220 t.Fatal(err)
221 }
222 if err := retentionPass(ctx, s, &boxReports{status: release.NewDirStore(t.TempDir())}, pub, reg); err != nil {
223 t.Fatal(err)
224 }
225 if _, _, err := s.Get(ctx, release.ManifestKey("bab", "kass", "v01")); err != nil {
226 t.Fatalf("no policy, yet v01 was pruned: %v", err)
227 }
228}
229