carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3import (
4 "context"
5 "errors"
6 "net/http"
7 "os"
8 "strings"
9 "testing"
10 "time"
11
12 "github.com/carlosframework/platform/internal/provision"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// The channel an instance record is BORN on, when the caller named none.
17//
18// This is the resolution amadan carlos/platform#5 is about: a brand-new app
19// gets a one-channel pipeline whose entry is "edge"
20// (release.DefaultPipelineChannels), `carlos deploy` resolves its own
21// channel from that entry (deployTargets), and this door used to answer an
22// omitted channel with the manifest's, then a flat "stable". So the first
23// instance of a new app followed a channel nothing ever promotes to, and the
24// first deploy hit stable's passkey wall with no way past it that did not
25// also need a passkey.
26//
27// The rails these tests pin, in the order the door applies them:
28//
29// explicit channel never reads the pipeline at all
30// manifest channel app policy, set at enable, still wins
31// sibling records this app's other instances, when they agree
32// pipeline entry where this app's releases actually land
33//
34// and, for the two cases where guessing would be worse than refusing: an
35// unreadable pipeline is a 502, and siblings that disagree are a 422 naming
36// the flag.
37
38// unchanneledManifest is titoManifest without the channel — an app whose
39// enable never named one, which is what the member-facing enable door
40// writes when `carlos instances enable` is run without -channel.
41func unchanneledManifest() provision.Manifest {
42 m := titoManifest()
43 m.Channel = ""
44 return m
45}
46
47// pipelineNamed is setPipeline (pendingflow_test.go) taking bare channel
48// names — these tests care about the ORDER of the list, never a channel's
49// bake or rules.
50func pipelineNamed(t *testing.T, fx *consoleFixture, app string, names ...string) {
51 t.Helper()
52 chans := make([]release.PipelineChannel, len(names))
53 for i, n := range names {
54 chans[i] = release.PipelineChannel{Name: n}
55 }
56 setPipeline(t, fx, app, chans...)
57}
58
59// sibling writes one existing instance record for the app, straight into the
60// bucket — an instance that already exists when the next create arrives.
61func sibling(t *testing.T, fx *consoleFixture, app, host, channel string) {
62 t.Helper()
63 if err := provision.CreateRecord(context.Background(), fx.rel, provision.Record{
64 Schema: 1, Host: host, Slug: strings.SplitN(host, ".", 2)[0],
65 App: app, Account: release.OpsAccount, Region: "ie", Channel: channel,
66 CreatedAt: time.Now().UTC(), CreatedBy: "paul@keymail.dev",
67 }); err != nil {
68 t.Fatal(err)
69 }
70}
71
72// createdChannel POSTs a create with no channel field and returns the
73// channel the door stamped on the STORED record — the bucket copy is what
74// the reconcile pass mints the route from, so it is the one that matters.
75func createdChannel(t *testing.T, fx *consoleFixture, token, app, host string) string {
76 t.Helper()
77 status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", app), token,
78 `{"host":"`+host+`","region":"ie"}`)
79 if status != http.StatusCreated {
80 t.Fatalf("create = %d %s, want 201", status, out)
81 }
82 rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount, app, host)
83 if err != nil {
84 t.Fatal(err)
85 }
86 return rec.Channel
87}
88
89// TestAPIInstanceCreateDefaultsToPipelineEntry is the bug itself: a fresh
90// app, its pipeline the one-channel starter every app is created with, and
91// nobody named a channel. The record must follow "edge" — where this app's
92// releases land — not "stable", which nothing promotes to and which no
93// member can reach without a passkey.
94func TestAPIInstanceCreateDefaultsToPipelineEntry(t *testing.T) {
95 fx, member := instanceFixture(t, "code-inst-chan-entry")
96 enableInstances(t, fx, "titogo", unchanneledManifest())
97 pipelineNamed(t, fx, "titogo", "edge")
98
99 if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "edge" {
100 t.Fatalf("stored channel = %q, want edge — the app's pipeline entry", got)
101 }
102}
103
104// TestAPIInstanceCreateRenamedPipelineEntry: the entry is whatever the app's
105// own pipeline says it is, not the word "edge". An account that declared
106// [preview, live] gets preview — the reason this reads the pipeline instead
107// of hard-coding the ladder's entry rung.
108func TestAPIInstanceCreateRenamedPipelineEntry(t *testing.T) {
109 fx, member := instanceFixture(t, "code-inst-chan-renamed")
110 enableInstances(t, fx, "titogo", unchanneledManifest())
111 pipelineNamed(t, fx, "titogo", "preview", "live")
112
113 if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "preview" {
114 t.Fatalf("stored channel = %q, want preview — this app's own entry channel", got)
115 }
116}
117
118// TestAPIInstanceCreateFollowsItsSiblings is the rail that keeps this change
119// out of production: an app whose instances already run on one channel mints
120// its NEXT instance on that same channel. Tito adds a record per customer;
121// a new one silently landing on the pipeline entry would serve that customer
122// the edge build.
123func TestAPIInstanceCreateFollowsItsSiblings(t *testing.T) {
124 fx, member := instanceFixture(t, "code-inst-chan-siblings")
125 enableInstances(t, fx, "titogo", unchanneledManifest())
126 pipelineNamed(t, fx, "titogo", "edge", "beta", "stable")
127 sibling(t, fx, "titogo", "one.go.tito.io", "beta")
128 sibling(t, fx, "titogo", "two.go.tito.io", "beta")
129
130 if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "beta" {
131 t.Fatalf("stored channel = %q, want beta — the channel this app's instances are on", got)
132 }
133}
134
135// TestAPIInstanceCreateIgnoresCanarySiblings: canary/* instances are the
136// throwaway kind — created to verify a build, deleted afterwards (the very
137// thing #5's reporter did) — so they do not get to decide what a real
138// instance follows. With only canary siblings the door falls through to the
139// pipeline entry.
140func TestAPIInstanceCreateIgnoresCanarySiblings(t *testing.T) {
141 fx, member := instanceFixture(t, "code-inst-chan-canary")
142 enableInstances(t, fx, "titogo", unchanneledManifest())
143 pipelineNamed(t, fx, "titogo", "edge")
144 sibling(t, fx, "titogo", "check.go.tito.io", "canary/boot-check")
145
146 if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "edge" {
147 t.Fatalf("stored channel = %q, want edge — a canary sibling is not a policy", got)
148 }
149}
150
151// TestAPIInstanceCreateAmbiguousSiblingsRefuse: an app whose instances
152// follow SEVERAL channels has no default worth guessing — picking either one
153// silently decides which build a new host serves. The refusal is
154// attachChannel's, in the same words and for the same reason: say which.
155func TestAPIInstanceCreateAmbiguousSiblingsRefuse(t *testing.T) {
156 fx, member := instanceFixture(t, "code-inst-chan-ambiguous")
157 enableInstances(t, fx, "titogo", unchanneledManifest())
158 pipelineNamed(t, fx, "titogo", "edge", "beta", "stable")
159 sibling(t, fx, "titogo", "one.go.tito.io", "edge")
160 sibling(t, fx, "titogo", "two.go.tito.io", "stable")
161
162 status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
163 `{"host":"acme.go.tito.io","region":"ie"}`)
164 if status != http.StatusUnprocessableEntity {
165 t.Fatalf("create with disagreeing siblings = %d %s, want 422", status, out)
166 }
167 if !strings.Contains(out, "edge") || !strings.Contains(out, "stable") {
168 t.Fatalf("refusal %s does not name the channels found", out)
169 }
170 if !strings.Contains(out, "channel") {
171 t.Fatalf("refusal %s does not name what to pass", out)
172 }
173 // A refused create writes nothing.
174 if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount,
175 "titogo", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
176 t.Fatalf("a refused create left a record behind: %v", err)
177 }
178}
179
180// TestAPIInstanceCreateLegacyAppDefaultsToEdge: an app from before pipelines
181// v2 has no pipeline.json, and the ladder it falls back to has the same
182// entry — nothing is promoted ONTO edge; merging is what puts a build there
183// (release/ladder.go). So a first instance lands there too.
184func TestAPIInstanceCreateLegacyAppDefaultsToEdge(t *testing.T) {
185 fx, member := instanceFixture(t, "code-inst-chan-legacy")
186 enableInstances(t, fx, "titogo", unchanneledManifest())
187
188 if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "edge" {
189 t.Fatalf("stored channel = %q, want edge — the ladder's entry rung", got)
190 }
191}
192
193// TestAPIInstanceCreateUnreadablePipelineRefuses: an unreadable pipeline is
194// not an absent one (apipipeline.go's stated rule). Defaulting through it to
195// "stable" would mint a production route because the bucket hiccuped, and a
196// record's channel is not something this door can take back.
197func TestAPIInstanceCreateUnreadablePipelineRefuses(t *testing.T) {
198 fx, member := instanceFixture(t, "code-inst-chan-unreadable")
199 enableInstances(t, fx, "titogo", unchanneledManifest())
200 if _, err := fx.rel.Put(context.Background(),
201 release.PipelineKey(release.OpsAccount, "titogo"), []byte("not json"), ""); err != nil {
202 t.Fatal(err)
203 }
204
205 status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
206 `{"host":"acme.go.tito.io","region":"ie"}`)
207 if status != http.StatusBadGateway {
208 t.Fatalf("create against an unreadable pipeline = %d %s, want 502", status, out)
209 }
210 if !strings.Contains(out, "channel") {
211 t.Fatalf("refusal %s does not tell the caller they can name a channel", out)
212 }
213 if _, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount,
214 "titogo", "acme.go.tito.io"); !errors.Is(err, os.ErrNotExist) {
215 t.Fatalf("a refused create left a record behind: %v", err)
216 }
217}
218
219// TestAPIInstanceCreateExplicitChannelNeverReadsThePipeline: a named channel
220// is the caller's decision and is stamped as given — even when the pipeline
221// object is unreadable and even when it names a channel the pipeline does
222// not list. This door validates channel SHAPE, never membership: canary/*
223// lives outside every pipeline, and a channel a member is about to ship to
224// is a legitimate target.
225func TestAPIInstanceCreateExplicitChannelNeverReadsThePipeline(t *testing.T) {
226 fx, member := instanceFixture(t, "code-inst-chan-explicit")
227 enableInstances(t, fx, "titogo", unchanneledManifest())
228 if _, err := fx.rel.Put(context.Background(),
229 release.PipelineKey(release.OpsAccount, "titogo"), []byte("not json"), ""); err != nil {
230 t.Fatal(err)
231 }
232
233 status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member,
234 `{"host":"acme.go.tito.io","region":"ie","channel":"canary/boot-check"}`)
235 if status != http.StatusCreated {
236 t.Fatalf("explicit-channel create = %d %s, want 201", status, out)
237 }
238 rec, err := provision.GetRecord(context.Background(), fx.rel, release.OpsAccount,
239 "titogo", "acme.go.tito.io")
240 if err != nil {
241 t.Fatal(err)
242 }
243 if rec.Channel != "canary/boot-check" {
244 t.Fatalf("stored channel = %q, want the requested canary/boot-check", rec.Channel)
245 }
246}
247
248// TestAPIInstanceCreateManifestChannelStillWins pins the rung ABOVE the
249// pipeline: `carlos instances enable -channel stable` is app policy, and an
250// app that carries one is not re-decided by its pipeline. This is what keeps
251// tito's per-customer creates landing on stable.
252func TestAPIInstanceCreateManifestChannelStillWins(t *testing.T) {
253 fx, member := instanceFixture(t, "code-inst-chan-manifest")
254 enableInstances(t, fx, "titogo", titoManifest()) // Channel: "stable"
255 pipelineNamed(t, fx, "titogo", "edge")
256
257 if got := createdChannel(t, fx, member, "titogo", "acme.go.tito.io"); got != "stable" {
258 t.Fatalf("stored channel = %q, want the manifest's stable", got)
259 }
260}
261