carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package release
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "os"
11 "strings"
12 "testing"
13
14 "github.com/carlosframework/platform/internal/registry"
15)
16
17// erroringStore is a Store whose every operation fails with a NON-not-found
18// error — the "bucket on fire" case a read must never swallow.
19type erroringStore struct{ err error }
20
21func (e *erroringStore) Get(context.Context, string) ([]byte, string, error) {
22 return nil, "", e.err
23}
24func (e *erroringStore) Put(context.Context, string, []byte, string) (string, error) {
25 return "", e.err
26}
27func (e *erroringStore) List(context.Context, string) ([]string, error) { return nil, e.err }
28func (e *erroringStore) ListDirs(context.Context, string) ([]string, error) { return nil, e.err }
29func (e *erroringStore) Delete(context.Context, string) error { return e.err }
30
31// legacyTripwire fails the test if anything asks the store for a key or
32// prefix under one app's LEGACY unqualified prefix. It is how "the runtime
33// does not read legacy keys" is pinned as a property rather than asserted one
34// call site at a time: a fallback that crept back into any read path would
35// trip it, whatever shape it took.
36type legacyTripwire struct {
37 Store
38 t *testing.T
39 app string
40}
41
42func (l *legacyTripwire) tripped(what, key string) {
43 l.t.Helper()
44 if strings.HasPrefix(key, LegacyAppPrefix(l.app)) {
45 l.t.Errorf("the runtime %s a legacy key: %s — the read-fallback is retired", what, key)
46 }
47}
48
49func (l *legacyTripwire) Get(ctx context.Context, key string) ([]byte, string, error) {
50 l.tripped("read", key)
51 return l.Store.Get(ctx, key)
52}
53
54// Open is tripped too: staging streams the artifact (#269), and without this
55// a streamed read would reach the store under the tripwire unwatched.
56func (l *legacyTripwire) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
57 l.tripped("read", key)
58 return OpenObject(ctx, l.Store, key)
59}
60
61func (l *legacyTripwire) List(ctx context.Context, prefix string) ([]string, error) {
62 l.tripped("listed", prefix)
63 return l.Store.List(ctx, prefix)
64}
65
66func TestKeysAreAccountScoped(t *testing.T) {
67 if got := ChannelKey("kqmv", "kass", "stable"); got != "apps/kqmv/kass/channels/stable.json" {
68 t.Errorf("ChannelKey = %q", got)
69 }
70 if got := ManifestKey("bab", "console", "abc1234"); got != "apps/bab/console/releases/abc1234/manifest.json" {
71 t.Errorf("ManifestKey = %q", got)
72 }
73 if got := ArtifactKey("kqmv", "kass", "v1", "kass"); got != "apps/kqmv/kass/releases/v1/kass" {
74 t.Errorf("ArtifactKey = %q", got)
75 }
76 // The legacy shapes stay reachable BY NAME — no read path resolves
77 // through them any more, but `accounts migrate -from legacy` still has to
78 // be able to spell them, forever.
79 if got := LegacyChannelKey("kass", "stable"); got != "apps/kass/channels/stable.json" {
80 t.Errorf("LegacyChannelKey = %q", got)
81 }
82 if got := LegacyManifestKey("console", "abc1234"); got != "apps/console/releases/abc1234/manifest.json" {
83 t.Errorf("LegacyManifestKey = %q", got)
84 }
85 if got := LegacyArtifactKey("kass", "v1", "kass"); got != "apps/kass/releases/v1/kass" {
86 t.Errorf("LegacyArtifactKey = %q", got)
87 }
88}
89
90// An empty account must never silently rebuild a legacy key: that would make
91// an unqualified write look like a successful qualified one, and the
92// migration would then never notice the app had not moved.
93func TestEmptyAccountDoesNotRebuildALegacyKey(t *testing.T) {
94 if got := ChannelKey("", "kass", "stable"); got == LegacyChannelKey("kass", "stable") {
95 t.Errorf("ChannelKey(\"\", ...) = %q — an empty account rebuilt the legacy key", got)
96 }
97}
98
99// The retirement, at its smallest: a legacy object at the matching
100// unqualified key does not answer a qualified read. It used to.
101func TestGetQualifiedDoesNotFallBack(t *testing.T) {
102 s := NewDirStore(t.TempDir())
103 ctx := context.Background()
104 if _, err := s.Put(ctx, "apps/kass/channels/stable.json", []byte("legacy"), ""); err != nil {
105 t.Fatal(err)
106 }
107 if _, _, err := GetQualified(ctx, s, "apps/kqmv/kass/channels/stable.json"); !errors.Is(err, os.ErrNotExist) {
108 t.Fatalf("a legacy object answered a qualified read: err = %v", err)
109 }
110 if _, err := s.Put(ctx, "apps/kqmv/kass/channels/stable.json", []byte("qualified"), ""); err != nil {
111 t.Fatal(err)
112 }
113 body, _, err := GetQualified(ctx, s, "apps/kqmv/kass/channels/stable.json")
114 if err != nil || string(body) != "qualified" {
115 t.Fatalf("qualified read: body=%q err=%v", body, err)
116 }
117}
118
119// A miss is actionable: a deployment that upgraded from a pre-accounts binary
120// without migrating gets the exact command, not a mystery 404. It is still
121// os.ErrNotExist-wrapped, so every caller's "has this app ever shipped here"
122// check keeps working.
123func TestQualifiedMissNamesTheMigrationCommand(t *testing.T) {
124 s := NewDirStore(t.TempDir())
125 _, _, err := GetQualified(context.Background(), s, ChannelKey("kqmv", "kass", "stable"))
126 if !errors.Is(err, os.ErrNotExist) {
127 t.Fatalf("miss = %v, want os.ErrNotExist-wrapped", err)
128 }
129 if !strings.Contains(err.Error(), "carlos accounts migrate --app kass --from legacy --to kqmv") {
130 t.Errorf("miss does not name the fix:\n%s", err)
131 }
132}
133
134// The hint is only for app keys. A miss on anything else (status snapshots,
135// account manifests) must not advertise a command that cannot apply to it.
136func TestNonAppKeyMissCarriesNoMigrationHint(t *testing.T) {
137 s := NewDirStore(t.TempDir())
138 _, _, err := GetQualified(context.Background(), s, "status/fleet.json")
139 if !errors.Is(err, os.ErrNotExist) {
140 t.Fatalf("miss = %v, want os.ErrNotExist-wrapped", err)
141 }
142 if strings.Contains(err.Error(), "accounts migrate") {
143 t.Errorf("status/fleet.json miss suggested a migration:\n%s", err)
144 }
145}
146
147// A store error that is NOT not-found must surface untouched, and must never
148// acquire a migration hint: telling an operator to migrate because the bucket
149// is unreachable sends them at the wrong problem.
150func TestGetQualifiedPropagatesRealErrors(t *testing.T) {
151 s := &erroringStore{err: errors.New("bucket on fire")}
152 _, _, err := GetQualified(context.Background(), s, ChannelKey("kqmv", "kass", "stable"))
153 if err == nil || !strings.Contains(err.Error(), "on fire") {
154 t.Fatalf("want the real error, got %v", err)
155 }
156 if strings.Contains(err.Error(), "accounts migrate") {
157 t.Errorf("a broken bucket was reported as an unmigrated app:\n%s", err)
158 }
159}
160
161// THE test the retirement exists for: an app whose objects are ONLY at the
162// legacy keys is invisible to every runtime read path. Nothing resolves it,
163// nothing lists it, and — the tripwire — nothing so much as touches its
164// legacy prefix on the way to finding that out.
165//
166// Adopt SKIPS such an app rather than failing the pass: one app's missing
167// pointer must not stop every other app on the box from installing, which is
168// already how a channel with nothing promoted behaves. The loudness lives in
169// the error text (asserted here), which adopt prints per channel.
170func TestLegacyOnlyAppIsInvisibleToTheRuntime(t *testing.T) {
171 dir := NewDirStore(t.TempDir())
172 ctx := context.Background()
173 priv, pub := testKeys(t)
174 s := &legacyTripwire{Store: dir, t: t, app: "old"}
175
176 // A wholly legacy app: manifest, artifact and pointer all unqualified.
177 m := Manifest{Schema: 1, App: "old", Version: "v1", Kind: "binary",
178 Artifacts: map[string]Artifact{"linux-amd64": {File: "old", SHA256: etagOf([]byte("bin")), Size: 3}}}
179 raw, err := json.MarshalIndent(m, "", " ")
180 if err != nil {
181 t.Fatal(err)
182 }
183 if _, err := dir.Put(ctx, LegacyArtifactKey("old", "v1", "old"), []byte("bin"), ""); err != nil {
184 t.Fatal(err)
185 }
186 if _, err := dir.Put(ctx, LegacyManifestKey("old", "v1"), raw, ""); err != nil {
187 t.Fatal(err)
188 }
189 ptr, err := signPointer(RootSigner(priv), &ChannelPointer{Account: "bab", App: "old", Channel: "stable", Version: "v1"})
190 if err != nil {
191 t.Fatal(err)
192 }
193 if _, err := dir.Put(ctx, LegacyChannelKey("old", "stable"), ptr, ""); err != nil {
194 t.Fatal(err)
195 }
196
197 if _, err := GetManifest(ctx, s, OpsAccount, "old", "v1"); !errors.Is(err, os.ErrNotExist) {
198 t.Errorf("GetManifest resolved a legacy-only app: %v", err)
199 } else if !strings.Contains(err.Error(), "--from legacy") {
200 t.Errorf("GetManifest miss is not actionable:\n%s", err)
201 }
202 if _, err := Channel(ctx, s, pub, OpsAccount, "old", "stable"); !errors.Is(err, os.ErrNotExist) {
203 t.Errorf("Channel resolved a legacy-only app: %v", err)
204 } else if !strings.Contains(err.Error(), "carlos accounts migrate --app old --from legacy --to bab") {
205 t.Errorf("Channel miss is not actionable:\n%s", err)
206 }
207 // Listing paths see nothing either, so the console and `carlos channels`
208 // show an empty app rather than a row they cannot resolve.
209 keys, err := s.List(ctx, ChannelsPrefix(OpsAccount, "old"))
210 if err != nil || len(keys) != 0 {
211 t.Errorf("channels listing = %v (err %v), want empty", keys, err)
212 }
213 rung, err := VersionRung(ctx, s, pub, OpsAccount, "old", "v1")
214 if err != nil || rung != "" {
215 t.Errorf("VersionRung = %q (err %v), want no rung", rung, err)
216 }
217}
218
219// A tripwire only guards the paths a test routes through it, so the ARTIFACT
220// download — the read that puts bytes on a box — gets its own pass. Its
221// fixture is the half-migrated shape that makes the read observable: pointer
222// and manifest qualified (so the pass gets as far as staging), the artifact
223// itself only at the legacy key. A fallback reintroduced in ensureStaged
224// would find those bytes and stage them; qualified-only cannot, so the host
225// is left alone and the pass survives.
226//
227// Without this, a fallback in ensureStaged passes the whole suite.
228func TestAdoptNeverDownloadsALegacyArtifact(t *testing.T) {
229 dir := NewDirStore(t.TempDir())
230 ctx := context.Background()
231 priv, pub := testKeys(t)
232 opt, reg := t.TempDir(), testRegistry(t)
233
234 // Built in a scratch store, then placed key by key: the qualified pointer
235 // and manifest go down as they are, the artifact only in its legacy shape.
236 scratch := NewDirStore(t.TempDir())
237 shipRelease(t, scratch, OpsAccount, "half", "v1", adoptPlatform, []byte("build one"))
238 if err := Promote(ctx, scratch, priv, OpsAccount, "half", "canary/x", "v1"); err != nil {
239 t.Fatal(err)
240 }
241 art := ""
242 keys, err := scratch.List(ctx, AppPrefix(OpsAccount, "half"))
243 if err != nil {
244 t.Fatal(err)
245 }
246 for _, k := range keys {
247 body, _, err := scratch.Get(ctx, k)
248 if err != nil {
249 t.Fatal(err)
250 }
251 dst := k
252 if strings.Contains(k, "/releases/") && !strings.HasSuffix(k, "manifest.json") {
253 art = k
254 dst = LegacyAppPrefix("half") + strings.TrimPrefix(k, AppPrefix(OpsAccount, "half"))
255 }
256 if _, err := dir.Put(ctx, dst, body, ""); err != nil {
257 t.Fatal(err)
258 }
259 }
260 if art == "" {
261 t.Fatal("fixture: no artifact key found")
262 }
263 if _, _, err := dir.Get(ctx, art); !errors.Is(err, os.ErrNotExist) {
264 t.Fatalf("fixture: the qualified artifact exists (%v) — the test would prove nothing", err)
265 }
266
267 s := &legacyTripwire{Store: dir, t: t, app: "half"}
268 addRoute(t, reg, "half.example.com", "half", "canary/x", false)
269 if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
270 t.Fatalf("one app's missing artifact must not fail the pass: %v", err)
271 }
272 if _, err := os.Stat(VersionPath(opt, OpsAccount, "v1", "half")); err == nil {
273 t.Error("adopt staged a build whose artifact only exists at the legacy key")
274 }
275 if v, _ := LiveVersion(opt, "half.example.com"); v != "" {
276 t.Errorf("host repointed to %q on a build that was never staged", v)
277 }
278}
279
280// The other half of the bargain: the migration verb's access to legacy keys
281// is EXPLICIT, and outlives the fallback. It is the upgrade path for any
282// deployment arriving from a pre-accounts binary, so it works forever.
283func TestGetMigrationSourceStillReadsLegacy(t *testing.T) {
284 s := NewDirStore(t.TempDir())
285 ctx := context.Background()
286 if _, err := s.Put(ctx, LegacyChannelKey("old", "stable"), []byte("legacy"), ""); err != nil {
287 t.Fatal(err)
288 }
289 body, _, usedLegacy, err := GetMigrationSource(ctx, s,
290 ChannelKey(OpsAccount, "old", "stable"), LegacyChannelKey("old", "stable"))
291 if err != nil || string(body) != "legacy" || !usedLegacy {
292 t.Fatalf("migration source: body=%q usedLegacy=%v err=%v", body, usedLegacy, err)
293 }
294 // Qualified still wins where both exist — that precedence is what stops
295 // the verb copying a stale legacy pointer over a live qualified one.
296 if _, err := s.Put(ctx, ChannelKey(OpsAccount, "old", "stable"), []byte("qualified"), ""); err != nil {
297 t.Fatal(err)
298 }
299 body, _, usedLegacy, err = GetMigrationSource(ctx, s,
300 ChannelKey(OpsAccount, "old", "stable"), LegacyChannelKey("old", "stable"))
301 if err != nil || string(body) != "qualified" || usedLegacy {
302 t.Fatalf("qualified must win: body=%q usedLegacy=%v err=%v", body, usedLegacy, err)
303 }
304}
305
306// A MIGRATED app waiting on its first promotion to a channel is ordinary,
307// and must not be told to migrate. This is kass's live shape on the flagship:
308// fully migrated to its own account, with a route following stable whose
309// pointer does not exist until the bake window ends — so the hourly adopt
310// would otherwise nag about migrating an app that is already migrated, every
311// hour, forever.
312//
313// The distinction is the same one PromoteOnLadder makes, and it is computed
314// from the qualified prefix alone: nothing there at all means the deployment
315// never migrated; anything there means this channel simply has no pointer yet.
316func TestAdoptDoesNotSuggestMigrationForAMigratedApp(t *testing.T) {
317 s := NewDirStore(t.TempDir())
318 ctx := context.Background()
319 priv, pub := testKeys(t)
320 opt, reg := t.TempDir(), testRegistry(t)
321
322 // Migrated and live on its own account — just not on stable yet.
323 shipRelease(t, s, "bad", "kass", "v1", adoptPlatform, []byte("build one"))
324 if err := Promote(ctx, s, priv, "bad", "kass", "canary/x", "v1"); err != nil {
325 t.Fatal(err)
326 }
327 if err := reg.Upsert(registry.Route{Host: "kass.bad.oncarlos.test", Kind: "instance",
328 App: "kass", Account: "bad", Channel: "stable"}); err != nil {
329 t.Fatal(err)
330 }
331
332 var out bytes.Buffer
333 withCapturedStdout(t, &out, func() {
334 if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
335 t.Errorf("Pass: %v", err)
336 }
337 })
338 // The PLAIN line exactly — "channel has no release yet" followed by
339 // nothing. A bare Contains on the prefix would also match the hint line
340 // ("channel has no release yet (…migrate…)"), making the positive half of
341 // this test vacuous (2026-08-03 review): only pinning the line ending
342 // keeps it a real assertion about WHICH branch printed.
343 if !strings.Contains(out.String(), "channel has no release yet\n") {
344 t.Errorf("want the plain skip line and nothing appended to it, got:\n%s", out.String())
345 }
346 if strings.Contains(out.String(), "accounts migrate") {
347 t.Errorf("a migrated app was told to migrate:\n%s", out.String())
348 }
349}
350
351// countingStore counts listings of one exact prefix.
352type countingStore struct {
353 Store
354 prefix string
355 n int
356}
357
358func (c *countingStore) List(ctx context.Context, prefix string) ([]string, error) {
359 if prefix == c.prefix {
360 c.n++
361 }
362 return c.Store.List(ctx, prefix)
363}
364
365// The check that decides the line costs one listing per (account, app) per
366// pass, not one per channel. A box following several of an app's channels —
367// the ordinary shape for a canary fleet — must not pay for the distinction
368// once per route.
369func TestAdoptListsAnAppsPrefixOncePerPass(t *testing.T) {
370 dir := NewDirStore(t.TempDir())
371 ctx := context.Background()
372 priv, pub := testKeys(t)
373 opt, reg := t.TempDir(), testRegistry(t)
374
375 shipRelease(t, dir, "bad", "kass", "v1", adoptPlatform, []byte("build one"))
376 if err := Promote(ctx, dir, priv, "bad", "kass", "canary/x", "v1"); err != nil {
377 t.Fatal(err)
378 }
379 // Three routes, three channels with no pointer, one app.
380 for i, ch := range []string{"stable", "beta", "edge"} {
381 if err := reg.Upsert(registry.Route{Host: fmt.Sprintf("h%d.example.com", i),
382 Kind: "instance", App: "kass", Account: "bad", Channel: ch}); err != nil {
383 t.Fatal(err)
384 }
385 }
386
387 s := &countingStore{Store: dir, prefix: AppPrefix("bad", "kass")}
388 var out bytes.Buffer
389 withCapturedStdout(t, &out, func() {
390 if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
391 t.Errorf("Pass: %v", err)
392 }
393 })
394 if s.n != 1 {
395 t.Errorf("listed %s %d times in one pass, want 1", s.prefix, s.n)
396 }
397 if strings.Contains(out.String(), "accounts migrate") {
398 t.Errorf("a migrated app was told to migrate:\n%s", out.String())
399 }
400}
401
402// The other side of the same line: an app with NOTHING under its qualified
403// prefix is the deployment that upgraded without migrating, and it gets the
404// verb by name. Reserving the advice is what makes it worth reading.
405func TestAdoptSuggestsMigrationForAnUnmigratedApp(t *testing.T) {
406 dir := NewDirStore(t.TempDir())
407 ctx := context.Background()
408 priv, pub := testKeys(t)
409 opt, reg := t.TempDir(), testRegistry(t)
410
411 scratch := NewDirStore(t.TempDir())
412 shipRelease(t, scratch, OpsAccount, "old", "v1", adoptPlatform, []byte("build one"))
413 if err := Promote(ctx, scratch, priv, OpsAccount, "old", "stable", "v1"); err != nil {
414 t.Fatal(err)
415 }
416 keys, err := scratch.List(ctx, AppPrefix(OpsAccount, "old"))
417 if err != nil {
418 t.Fatal(err)
419 }
420 for _, k := range keys {
421 body, _, err := scratch.Get(ctx, k)
422 if err != nil {
423 t.Fatal(err)
424 }
425 legacy := LegacyAppPrefix("old") + strings.TrimPrefix(k, AppPrefix(OpsAccount, "old"))
426 if _, err := dir.Put(ctx, legacy, body, ""); err != nil {
427 t.Fatal(err)
428 }
429 }
430
431 s := &legacyTripwire{Store: dir, t: t, app: "old"}
432 addRoute(t, reg, "old.example.com", "old", "stable", false)
433 var out bytes.Buffer
434 withCapturedStdout(t, &out, func() {
435 if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }); err != nil {
436 t.Errorf("Pass: %v", err)
437 }
438 })
439 if !strings.Contains(out.String(), "carlos accounts migrate --app old --from legacy --to bab") {
440 t.Errorf("an unmigrated app was not told how to migrate:\n%s", out.String())
441 }
442}
443
444// Promoting an app on a deployment that never migrated must say SO. Left to
445// the ladder, the answer is "stable is entered from beta only, and this build
446// is on no rung yet" — true (nothing qualified means no rung) and entirely
447// beside the point. The operator's actual problem is that this deployment's
448// objects are all at the account-less keys.
449//
450// The test drives it through the tripwire: the distinction is computed from
451// the qualified prefix alone, never by peeking at legacy.
452func TestPromoteOnLadderNamesTheMigrationForAnUnmigratedApp(t *testing.T) {
453 dir := NewDirStore(t.TempDir())
454 ctx := context.Background()
455 priv, _ := testKeys(t)
456 s := &legacyTripwire{Store: dir, t: t, app: "old"}
457
458 // A wholly legacy app, built in a scratch store and copied down.
459 scratch := NewDirStore(t.TempDir())
460 shipRelease(t, scratch, OpsAccount, "old", "v1", adoptPlatform, []byte("build one"))
461 if err := Promote(ctx, scratch, priv, OpsAccount, "old", "stable", "v1"); err != nil {
462 t.Fatal(err)
463 }
464 keys, err := scratch.List(ctx, AppPrefix(OpsAccount, "old"))
465 if err != nil {
466 t.Fatal(err)
467 }
468 for _, k := range keys {
469 body, _, err := scratch.Get(ctx, k)
470 if err != nil {
471 t.Fatal(err)
472 }
473 legacy := LegacyAppPrefix("old") + strings.TrimPrefix(k, AppPrefix(OpsAccount, "old"))
474 if _, err := dir.Put(ctx, legacy, body, ""); err != nil {
475 t.Fatal(err)
476 }
477 }
478
479 _, err = PromoteOnLadder(ctx, s, priv, OpsAccount, "old", "stable", "v1", PromoteOptions{})
480 if err == nil {
481 t.Fatal("promoting an unmigrated app succeeded")
482 }
483 if !strings.Contains(err.Error(), "carlos accounts migrate --app old --from legacy --to bab") {
484 t.Errorf("the refusal does not name the fix:\n%v", err)
485 }
486 if strings.Contains(err.Error(), "no rung yet") {
487 t.Errorf("the ladder answered instead of the migration:\n%v", err)
488 }
489}
490
491// The control for the test above, and the reason the condition is "nothing
492// qualified AT ALL" rather than "no qualified releases": an app that HAS
493// shipped under this account is not unmigrated, however new the target
494// channel is. It gets the ladder's answer, with no migration advice.
495func TestPromoteOnLadderKeepsTheLadderRefusalForAShippedApp(t *testing.T) {
496 s := NewDirStore(t.TempDir())
497 ctx := context.Background()
498 priv, _ := testKeys(t)
499
500 shipRelease(t, s, OpsAccount, "fresh", "v1", adoptPlatform, []byte("build one"))
501 _, err := PromoteOnLadder(ctx, s, priv, OpsAccount, "fresh", "stable", "v1", PromoteOptions{})
502 if err == nil {
503 t.Fatal("a fresh build entered stable directly")
504 }
505 if !strings.Contains(err.Error(), "no rung yet") {
506 t.Errorf("want the ladder's refusal, got:\n%v", err)
507 }
508 if strings.Contains(err.Error(), "accounts migrate") {
509 t.Errorf("a shipped app was told to migrate:\n%v", err)
510 }
511}
512
513// A store error that is not "not found" is never retried against legacy:
514// masking a broken bucket as a fallback miss would make it look like an
515// unmigrated app, and the migration would then "verify" an app it never read.
516func TestGetMigrationSourcePropagatesRealErrors(t *testing.T) {
517 s := &erroringStore{err: errors.New("bucket on fire")}
518 _, _, _, err := GetMigrationSource(context.Background(), s, "a", "b")
519 if err == nil || !strings.Contains(err.Error(), "on fire") {
520 t.Fatalf("want the real error, got %v", err)
521 }
522}
523
524// Promote against an app whose pointer only exists at the legacy key does not
525// see it: the new pointer is a FIRST promote at the qualified key, and the
526// legacy history does not come forward. That is the retirement's real cost,
527// stated — carrying history across is what `accounts migrate` is for, and it
528// is why a miss names that verb. The legacy object is still never deleted.
529func TestPromoteDoesNotInheritALegacyPointer(t *testing.T) {
530 s := NewDirStore(t.TempDir())
531 ctx := context.Background()
532 priv, pub := testKeys(t)
533
534 shipRelease(t, s, "kqmv", "myapp", "v1", "linux-amd64", []byte("v1"))
535 shipRelease(t, s, "kqmv", "myapp", "v2", "linux-amd64", []byte("v2"))
536 legacy, err := signPointer(RootSigner(priv), &ChannelPointer{Account: "bab", App: "myapp", Channel: "stable", Version: "v1"})
537 if err != nil {
538 t.Fatal(err)
539 }
540 if _, err := s.Put(ctx, LegacyChannelKey("myapp", "stable"), legacy, ""); err != nil {
541 t.Fatal(err)
542 }
543
544 if err := Promote(ctx, s, priv, "kqmv", "myapp", "stable", "v2"); err != nil {
545 t.Fatalf("Promote: %v", err)
546 }
547 ptr, err := Channel(ctx, s, pub, "kqmv", "myapp", "stable")
548 if err != nil {
549 t.Fatal(err)
550 }
551 if ptr.Version != "v2" {
552 t.Fatalf("pointer = %s, want v2", ptr.Version)
553 }
554 if len(ptr.History) != 0 {
555 t.Errorf("history = %v, want empty — a legacy pointer must not be inherited", ptr.History)
556 }
557 if _, _, err := s.Get(ctx, LegacyChannelKey("myapp", "stable")); err != nil {
558 t.Errorf("promote deleted the legacy pointer: %v", err)
559 }
560}
561