carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// End-to-end acceptance test for issue #66: `carlos instances
2// enable/create/list/delete` working through the console API with `carlos
3// auth login` alone — the last step between "promoted" and "reachable"
4// without a box shell or bucket credentials.
5//
6// Follows TestE2ECLIThroughConsole's shape (auth_e2e_test.go): a REAL
7// console.Server over a DirStore, a device-flow login landing a real bearer
8// token in a temp credential file, then — with NO store env set — the real
9// run* functions driven in-process. Ground truth is the DirStore's own
10// manifest and record objects, plus the `stdout` seam's confirmation lines
11// (these verbs print through cli.go's stdout var, so captureStdout reaches
12// them).
13package main
14
15import (
16 "context"
17 "fmt"
18 "net/http"
19 "net/http/httptest"
20 "path/filepath"
21 "strings"
22 "testing"
23 "time"
24
25 "github.com/carlosframework/platform/internal/appconfig"
26 "github.com/carlosframework/platform/internal/console"
27 "github.com/carlosframework/platform/internal/provision"
28 "github.com/carlosframework/platform/internal/release"
29 "github.com/carlosframework/platform/internal/status"
30)
31
32func TestE2EInstancesThroughConsole(t *testing.T) {
33 useTempCredentials(t)
34 clearDeploymentEnv(t)
35 checkNoRegistry := requireNoRegistryTouch(t)
36
37 tmp := t.TempDir()
38 relStore := release.NewDirStore(filepath.Join(tmp, "store"))
39 seed, pubB64, err := release.GenerateKey()
40 if err != nil {
41 t.Fatal(err)
42 }
43 priv, err := release.ParseSigningKey(seed, "e2e")
44 if err != nil {
45 t.Fatal(err)
46 }
47 pub, err := release.ParsePublicKey(pubB64, "e2e")
48 if err != nil {
49 t.Fatal(err)
50 }
51 cst, err := console.Open(filepath.Join(tmp, "console.db"))
52 if err != nil {
53 t.Fatal(err)
54 }
55 t.Cleanup(func() { cst.Close() })
56 // The -addr / set-upstreams leg below states upstreams, which only a
57 // platform operator may do.
58 if err := cst.SetOperator("paul@keymail.dev", true); err != nil {
59 t.Fatal(err)
60 }
61
62 var srv *console.Server
63 ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
64 srv.ServeHTTP(w, r)
65 }))
66 t.Cleanup(ts.Close)
67 srv, err = console.NewServer(console.Config{
68 Origin: ts.URL,
69 Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin
70 Registry: filepath.Join(tmp, "registry.db"),
71 Releases: relStore,
72 ReleasePub: pub,
73 Key: priv,
74 AppScheme: "http",
75 AppsDomain: "oncarlos.test",
76 }, cst)
77 if err != nil {
78 t.Fatal(err)
79 }
80
81 const addr = "paul@keymail.dev"
82 if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil {
83 t.Fatal(err)
84 }
85 if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", addr, time.Now()); err != nil {
86 t.Fatal(err)
87 }
88
89 token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop")
90 credFile, err := loadCredentials()
91 if err != nil {
92 t.Fatal(err)
93 }
94 credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt})
95 if err := credFile.save(); err != nil {
96 t.Fatal(err)
97 }
98
99 // --- enable: the console pins the domain to the account's platform
100 // subdomain; no -domain crosses the wire ---------------------------------
101 const domain = "bab.oncarlos.test"
102 buf := captureStdout(t)
103 runInstancesEnable([]string{"-app", "titogo", "-account", "ops", "-channel", "beta"})
104 if want := "enabled instances for titogo under " + domain; !strings.Contains(buf.String(), want) {
105 t.Fatalf("enable stdout = %q, want %q", buf.String(), want)
106 }
107 m, err := provision.GetManifest(context.Background(), relStore, release.OpsAccount, "titogo")
108 if err != nil {
109 t.Fatalf("manifest after enable: %v", err)
110 }
111 if m.Domain != domain || m.Channel != "beta" || m.Pool != "" {
112 t.Fatalf("manifest = %+v, want %s on beta with the shared pool", m, domain)
113 }
114
115 // --- create: the app's own platform host, one label under the pin ------
116 host := "titogo." + domain
117 buf.Reset()
118 runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", host})
119 if want := "created instance " + host + " (channel beta)"; !strings.Contains(buf.String(), want) {
120 t.Fatalf("create stdout = %q, want %q", buf.String(), want)
121 }
122 rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host)
123 if err != nil {
124 t.Fatalf("record after create: %v", err)
125 }
126 if rec.Channel != "beta" || rec.Slug != "titogo" || rec.CreatedBy != addr {
127 t.Fatalf("record = %+v, want beta/titogo created by %s", rec, addr)
128 }
129
130 // --- create -kind static: the flag reaches the record ------------------
131 // The shape `carlos deploy` mints for a static app's platform host. Here
132 // only to prove the FLAG is wired to the wire field the door has always
133 // read; the door's own acceptance of it is pinned console-side.
134 staticHost := "site." + domain
135 buf.Reset()
136 runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", staticHost, "-kind", "static"})
137 staticRec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", staticHost)
138 if err != nil {
139 t.Fatalf("record after create --kind static: %v", err)
140 }
141 if staticRec.Kind != "static" {
142 t.Fatalf("record = %+v, want kind static", staticRec)
143 }
144
145 // --- set-channel: the record is where a route's channel lives ----------
146 buf.Reset()
147 runInstancesSetChannel([]string{"-app", "titogo", "-account", "ops", "-host", host, "-channel", "edge"})
148 if want := "moved " + host + " to edge"; !strings.Contains(buf.String(), want) {
149 t.Fatalf("set-channel stdout = %q, want %q", buf.String(), want)
150 }
151 moved, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host)
152 if err != nil {
153 t.Fatalf("record after set-channel: %v", err)
154 }
155 if moved.Channel != "edge" {
156 t.Fatalf("record channel = %q, want edge", moved.Channel)
157 }
158 // And back, so the rest of this test still sees the channel it set up.
159 buf.Reset()
160 runInstancesSetChannel([]string{"-app", "titogo", "-account", "ops", "-host", host, "-channel", "beta"})
161
162 // --- list ----------------------------------------------------------------
163 buf.Reset()
164 runInstancesList([]string{"-app", "titogo", "-account", "ops"})
165 if out := buf.String(); !strings.Contains(out, host) || !strings.Contains(out, "beta") {
166 t.Fatalf("list stdout = %q, want the record's host and channel", out)
167 }
168
169 // --- delete: idempotent record retirement -------------------------------
170 buf.Reset()
171 runInstancesDelete([]string{"-app", "titogo", "-account", "ops", "-host", host})
172 if want := "deleted instance " + host; !strings.Contains(buf.String(), want) {
173 t.Fatalf("delete stdout = %q, want %q", buf.String(), want)
174 }
175 if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host); err == nil {
176 t.Fatalf("record survived delete")
177 }
178
179 // --- create with -addr/-health-*, then set-upstreams (Task 8) ----------
180 haHost := "ha." + domain
181 buf.Reset()
182 runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", haHost,
183 "-addr", "10.0.0.1:8090,10.0.0.2:8090", "-health-path", "/healthz", "-health-status", "204"})
184 if want := "created instance " + haHost; !strings.Contains(buf.String(), want) {
185 t.Fatalf("create --addr stdout = %q, want %q", buf.String(), want)
186 }
187 haRec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost)
188 if err != nil {
189 t.Fatalf("record after create --addr: %v", err)
190 }
191 if len(haRec.Addrs) != 2 || haRec.Addrs[0] != "10.0.0.1:8090" || haRec.Addrs[1] != "10.0.0.2:8090" {
192 t.Fatalf("record.Addrs = %v, want the two --addr entries", haRec.Addrs)
193 }
194 if haRec.Health == nil || haRec.Health.Path != "/healthz" || haRec.Health.Status != 204 {
195 t.Fatalf("record.Health = %+v, want {Path: /healthz, Status: 204}", haRec.Health)
196 }
197
198 // list must show the UPSTREAMS column, "-" here since no box has
199 // reported this fleet-fresh host through the status join yet.
200 buf.Reset()
201 runInstancesList([]string{"-app", "titogo", "-account", "ops"})
202 listOut := buf.String()
203 if !strings.Contains(listOut, "UPSTREAMS") {
204 t.Fatalf("list stdout = %q, want an UPSTREAMS column header", listOut)
205 }
206 if !strings.Contains(listOut, haHost) {
207 t.Fatalf("list stdout = %q, want the multi-upstream host listed", listOut)
208 }
209
210 // set-upstreams repoints the whole set and probe in one PATCH.
211 buf.Reset()
212 runInstancesSetUpstreams([]string{"-app", "titogo", "-account", "ops", "-host", haHost,
213 "-addrs", "10.0.0.3:8090", "-health-path", "/up", "-health-status", "200"})
214 if want := "updated upstreams for " + haHost; !strings.Contains(buf.String(), want) {
215 t.Fatalf("set-upstreams stdout = %q, want %q", buf.String(), want)
216 }
217 haRec, err = provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost)
218 if err != nil {
219 t.Fatalf("record after set-upstreams: %v", err)
220 }
221 if len(haRec.Addrs) != 1 || haRec.Addrs[0] != "10.0.0.3:8090" {
222 t.Fatalf("record.Addrs after set-upstreams = %v, want [10.0.0.3:8090]", haRec.Addrs)
223 }
224 if haRec.Health == nil || haRec.Health.Path != "/up" || haRec.Health.Status != 200 {
225 t.Fatalf("record.Health after set-upstreams = %+v, want {Path: /up, Status: 200}", haRec.Health)
226 }
227
228 // set-upstreams naming NEITHER -addrs nor a -health-* flag reaches the
229 // console's own refusal, surfaced verbatim rather than a client-side
230 // message that would paraphrase it (self-review rule this task carries).
231 // That path is log.Fatal, same as every other malformed-invocation path
232 // in this file (instances_cli_test.go's own doc comment: no argv test
233 // harness exists here for os.Exit paths) — pinned instead at the client
234 // layer, where the request/response round trip is actually observable:
235 // TestInstanceSetUpstreamsSurfacesRefusalVerbatim (apimode_test.go)
236 // proves instanceSetUpstreams returns the door's error text unmodified.
237
238 // --- steering (Task 3): a top-level verb, not an "instances" sub-verb --
239 // haHost's record lives under this manifest's shared pool (Pool == ""
240 // per the assertion at line 103-105 above), so it may opt into latency
241 // steering and back off.
242 buf.Reset()
243 runSteering([]string{"-app", "titogo", "-account", "ops", "-host", haHost, "latency"})
244 if want := "steering set to latency for " + haHost; !strings.Contains(buf.String(), want) {
245 t.Fatalf("steering stdout = %q, want %q", buf.String(), want)
246 }
247 haRec, err = provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost)
248 if err != nil {
249 t.Fatalf("record after steering latency: %v", err)
250 }
251 if haRec.Steering != "latency" {
252 t.Fatalf("record.Steering after steering latency = %q, want latency", haRec.Steering)
253 }
254 // Everything set-upstreams already wrote onto this record is undisturbed
255 // by a steering-only patch.
256 if len(haRec.Addrs) != 1 || haRec.Addrs[0] != "10.0.0.3:8090" {
257 t.Fatalf("steering patch moved addrs: %v", haRec.Addrs)
258 }
259
260 buf.Reset()
261 runSteering([]string{"-app", "titogo", "-account", "ops", "-host", haHost, "off"})
262 if want := "steering turned off for " + haHost; !strings.Contains(buf.String(), want) {
263 t.Fatalf("steering off stdout = %q, want %q", buf.String(), want)
264 }
265 haRec, err = provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost)
266 if err != nil {
267 t.Fatalf("record after steering off: %v", err)
268 }
269 if haRec.Steering != "" {
270 t.Fatalf("record.Steering after steering off = %q, want empty", haRec.Steering)
271 }
272
273 // The pool-conflict 409 (a record whose Pool != "") is a log.Fatal path
274 // through this verb, same as set-upstreams' own refusal above — no argv
275 // test harness exists here for os.Exit paths. It is proven at the console
276 // door (TestAPIInstanceUpdateSteeringPoolConflict, internal/console) and
277 // at this client's own layer (TestInstanceSetSteeringSurfacesRefusalVerbatim,
278 // apimode_test.go), which is where the request/response round trip is
279 // actually observable.
280
281 // --- apps set (zero-downtime plan 6, Task 5), in the spec's order --------
282 buf.Reset()
283 runAppsSet([]string{"titogo", "--swap", "hold", "-account", "ops"})
284 if want := fmt.Sprintf(appsSetDone, "titogo", "hold", 1); !strings.Contains(buf.String(), want) {
285 t.Fatalf("apps set stdout = %q, want %q", buf.String(), want)
286 }
287 policy, err := appconfig.GetSwapPolicy(context.Background(), relStore, release.OpsAccount, "titogo")
288 if err != nil || policy.Swap != appconfig.SwapHold {
289 t.Fatalf("stored policy = %+v, %v", policy, err)
290 }
291
292 // --- instances retry (Task 11): host first, as the spec writes it --------
293 box := status.CollectBox(nil, status.Probes{MachineID: "e2e-box"}, time.Now())
294 box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{
295 haHost: {Account: release.OpsAccount, App: "titogo", Installed: "v2", Serving: "v1", Good: "v1",
296 Awake: true, HeldBy: "v2", HeldEpoch: 9, HeldBook: "e2e-book", Reason: status.ReasonHealth,
297 RetryV: status.HostRetryV}, // the retry protocol this box takes (readiness round 2, Task 3)
298 }}}
299 if err := status.WriteBox(context.Background(), relStore, box); err != nil {
300 t.Fatal(err)
301 }
302 buf.Reset()
303 runInstancesRetry([]string{haHost, "-app", "titogo", "-account", "ops"})
304 if want := fmt.Sprintf(instancesRetryDone, haHost, "v2"); !strings.Contains(buf.String(), want) {
305 t.Fatalf("instances retry stdout = %q, want %q", buf.String(), want)
306 }
307 // Grants ride the app's host-retry log, not restart-scope.json (Task 3).
308 scopes, found, err := release.LoadHostRetries(context.Background(), relStore, release.OpsAccount, "titogo")
309 if err != nil || !found || len(scopes.Requests) == 0 {
310 t.Fatalf("host retries = %+v, %v, %v", scopes, found, err)
311 }
312 last := scopes.Requests[len(scopes.Requests)-1]
313 if last.Scope.Kind != release.RestartScopeRetry || last.Scope.Value != haHost {
314 t.Fatalf("last request = %+v", last)
315 }
316 // The entry carries the console's root-signed grant for the held build
317 // at the epoch and book the box reported (Task 8, spec §11; final review
318 // round 1, finding 3): the box clears nothing on an entry without one.
319 if g, err := release.VerifyHostRetry(pub, last.Scope.Signed, release.OpsAccount, "titogo", haHost, time.Now()); err != nil || g.Build != "v2" || g.Epoch != 9 || g.Book != "e2e-book" {
320 t.Fatalf("signed grant = %+v, %v; want v2 at epoch 9 of book e2e-book", g, err)
321 }
322
323 // The whole flow ran through the console: nothing here may have touched
324 // a local registry.
325 checkNoRegistry()
326}
327