package console import ( "context" "errors" "net/http" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // The channel an instance record is BORN on, when the caller named none. // // This is the resolution amadan carlos/platform#5 is about: a brand-new app // gets a one-channel pipeline whose entry is "edge" // (release.DefaultPipelineChannels), `carlos deploy` resolves its own // channel from that entry (deployTargets), and this door used to answer an // omitted channel with the manifest's, then a flat "stable". So the first // instance of a new app followed a channel nothing ever promotes to, and the // first deploy hit stable's passkey wall with no way past it that did not // also need a passkey. // // The rails these tests pin, in the order the door applies them: // // explicit channel never reads the pipeline at all // manifest channel app policy, set at enable, still wins // sibling records this app's other instances, when they agree // pipeline entry where this app's releases actually land // // and, for the two cases where guessing would be worse than refusing: an // unreadable pipeline is a 502, and siblings that disagree are a 422 naming // the flag. // unchanneledManifest is titoManifest without the channel — an app whose // enable never named one, which is what the member-facing enable door // writes when `carlos instances enable` is run without -channel. func unchanneledManifest() provision.Manifest { m := titoManifest() m.Channel = "" return m } // pipelineNamed is setPipeline (pendingflow_test.go) taking bare channel // names — these tests care about the ORDER of the list, never a channel's // bake or rules. func pipelineNamed(t *testing.T, fx *consoleFixture, app string, names ...string) { t.Helper() chans := make([]release.PipelineChannel, len(names)) for i, n := range names { chans[i] = release.PipelineChannel{Name: n} } setPipeline(t, fx, app, chans...) } // sibling writes one existing instance record for the app, straight into the // bucket — an instance that already exists when the next create arrives. func sibling(t *testing.T, fx *consoleFixture, app, host, channel string) { t.Helper() if err := provision.CreateRecord(context.Background(), fx.rel, provision.Record{ Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0], App: app, Account: release.OpsAccount, Region: "ie", Channel: channel, CreatedAt: time.Now().UTC(), CreatedBy: "paul@keymail.dev", }); err != nil { t.Fatal(err) } } // createdChannel POSTs a create with no channel field and returns the // channel the door stamped on the STORED record — the bucket copy is what // the reconcile pass mints the route from, so it is the one that matters. func createdChannel(t *testing.T, fx *consoleFixture, token, app, host string) string { t.Helper() status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", app), token, `{"host":"`+host+`","region":"ie"}`) if status != http.StatusCreated { t.Fatalf("create = %d %s, want 201", status, out) } rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, app, host) if err != nil { t.Fatal(err) } return rec.Channel } // TestAPIInstanceCreateDefaultsToPipelineEntry is the bug itself: a fresh // app, its pipeline the one-channel starter every app is created with, and // nobody named a channel. The record must follow "edge" — where this app's // releases land — not "stable", which nothing promotes to and which no // member can reach without a passkey. func TestAPIInstanceCreateDefaultsToPipelineEntry(t *testing.T) { fx, member := instanceFixture(t, "code-inst-chan-entry") enableInstances(t, fx, "titogo", unchanneledManifest()) pipelineNamed(t, fx, "titogo", "edge") if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "edge" { t.Fatalf("stored channel = %q, want edge — the app's pipeline entry", got) } } // TestAPIInstanceCreateRenamedPipelineEntry: the entry is whatever the app's // own pipeline says it is, not the word "edge". An account that declared // [preview, live] gets preview — the reason this reads the pipeline instead // of hard-coding the ladder's entry rung. func TestAPIInstanceCreateRenamedPipelineEntry(t *testing.T) { fx, member := instanceFixture(t, "code-inst-chan-renamed") enableInstances(t, fx, "titogo", unchanneledManifest()) pipelineNamed(t, fx, "titogo", "preview", "live") if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "preview" { t.Fatalf("stored channel = %q, want preview — this app's own entry channel", got) } } // TestAPIInstanceCreateFollowsItsSiblings is the rail that keeps this change // out of production: an app whose instances already run on one channel mints // its NEXT instance on that same channel. Tito adds a record per customer; // a new one silently landing on the pipeline entry would serve that customer // the edge build. func TestAPIInstanceCreateFollowsItsSiblings(t *testing.T) { fx, member := instanceFixture(t, "code-inst-chan-siblings") enableInstances(t, fx, "titogo", unchanneledManifest()) pipelineNamed(t, fx, "titogo", "edge", "beta", "stable") sibling(t, fx, "titogo", "one.go.tito.io", "beta") sibling(t, fx, "titogo", "two.go.tito.io", "beta") if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "beta" { t.Fatalf("stored channel = %q, want beta — the channel this app's instances are on", got) } } // TestAPIInstanceCreateIgnoresCanarySiblings: canary/* instances are the // throwaway kind — created to verify a build, deleted afterwards (the very // thing #5's reporter did) — so they do not get to decide what a real // instance follows. With only canary siblings the door falls through to the // pipeline entry. func TestAPIInstanceCreateIgnoresCanarySiblings(t *testing.T) { fx, member := instanceFixture(t, "code-inst-chan-canary") enableInstances(t, fx, "titogo", unchanneledManifest()) pipelineNamed(t, fx, "titogo", "edge") sibling(t, fx, "titogo", "check.go.tito.io", "canary/boot-check") if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "edge" { t.Fatalf("stored channel = %q, want edge — a canary sibling is not a policy", got) } } // TestAPIInstanceCreateAmbiguousSiblingsRefuse: an app whose instances // follow SEVERAL channels has no default worth guessing — picking either one // silently decides which build a new host serves. The refusal is // attachChannel's, in the same words and for the same reason: say which. func TestAPIInstanceCreateAmbiguousSiblingsRefuse(t *testing.T) { fx, member := instanceFixture(t, "code-inst-chan-ambiguous") enableInstances(t, fx, "titogo", unchanneledManifest()) pipelineNamed(t, fx, "titogo", "edge", "beta", "stable") sibling(t, fx, "titogo", "one.go.tito.io", "edge") sibling(t, fx, "titogo", "two.go.tito.io", "stable") status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"acme.go.tito.io","region":"ie"}`) if status != http.StatusUnprocessableEntity { t.Fatalf("create with disagreeing siblings = %d %s, want 422", status, out) } if !strings.Contains(out, "edge") || !strings.Contains(out, "stable") { t.Fatalf("refusal %s does not name the channels found", out) } if !strings.Contains(out, "channel") { t.Fatalf("refusal %s does not name what to pass", out) } // A refused create writes nothing. if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "titogo", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("a refused create left a record behind: %v", err) } } // TestAPIInstanceCreateLegacyAppDefaultsToEdge: an app from before pipelines // v2 has no pipeline.json, and the ladder it falls back to has the same // entry — nothing is promoted ONTO edge; merging is what puts a build there // (release/ladder.go). So a first instance lands there too. func TestAPIInstanceCreateLegacyAppDefaultsToEdge(t *testing.T) { fx, member := instanceFixture(t, "code-inst-chan-legacy") enableInstances(t, fx, "titogo", unchanneledManifest()) if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "edge" { t.Fatalf("stored channel = %q, want edge — the ladder's entry rung", got) } } // TestAPIInstanceCreateUnreadablePipelineRefuses: an unreadable pipeline is // not an absent one (apipipeline.go's stated rule). Defaulting through it to // "stable" would mint a production route because the bucket hiccuped, and a // record's channel is not something this door can take back. func TestAPIInstanceCreateUnreadablePipelineRefuses(t *testing.T) { fx, member := instanceFixture(t, "code-inst-chan-unreadable") enableInstances(t, fx, "titogo", unchanneledManifest()) if _, err := fx.rel.Put(context.Background(), release.PipelineKey(release.OpsAccount, "titogo"), []byte("not json"), ""); err != nil { t.Fatal(err) } status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"acme.go.tito.io","region":"ie"}`) if status != http.StatusBadGateway { t.Fatalf("create against an unreadable pipeline = %d %s, want 502", status, out) } if !strings.Contains(out, "channel") { t.Fatalf("refusal %s does not tell the caller they can name a channel", out) } if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "titogo", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("a refused create left a record behind: %v", err) } } // TestAPIInstanceCreateExplicitChannelNeverReadsThePipeline: a named channel // is the caller's decision and is stamped as given — even when the pipeline // object is unreadable and even when it names a channel the pipeline does // not list. This door validates channel SHAPE, never membership: canary/* // lives outside every pipeline, and a channel a member is about to ship to // is a legitimate target. func TestAPIInstanceCreateExplicitChannelNeverReadsThePipeline(t *testing.T) { fx, member := instanceFixture(t, "code-inst-chan-explicit") enableInstances(t, fx, "titogo", unchanneledManifest()) if _, err := fx.rel.Put(context.Background(), release.PipelineKey(release.OpsAccount, "titogo"), []byte("not json"), ""); err != nil { t.Fatal(err) } status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, `{"host":"acme.go.tito.io","region":"ie","channel":"canary/boot-check"}`) if status != http.StatusCreated { t.Fatalf("explicit-channel create = %d %s, want 201", status, out) } rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, "titogo", "acme.go.tito.io") if err != nil { t.Fatal(err) } if rec.Channel != "canary/boot-check" { t.Fatalf("stored channel = %q, want the requested canary/boot-check", rec.Channel) } } // TestAPIInstanceCreateManifestChannelStillWins pins the rung ABOVE the // pipeline: `carlos instances enable -channel stable` is app policy, and an // app that carries one is not re-decided by its pipeline. This is what keeps // tito's per-customer creates landing on stable. func TestAPIInstanceCreateManifestChannelStillWins(t *testing.T) { fx, member := instanceFixture(t, "code-inst-chan-manifest") enableInstances(t, fx, "titogo", titoManifest()) // Channel: "stable" pipelineNamed(t, fx, "titogo", "edge") if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "stable" { t.Fatalf("stored channel = %q, want the manifest's stable", got) } }