// End-to-end acceptance test for `carlos deploy` (spec §9's acceptance // debt): the one member-facing verb that is nothing but a composition — // ship, promote to the channel this app's instances already follow, then // watch the URL until it serves the new version — driven against a REAL // console.Server over a DirStore, with a real device-flow login, exactly // like TestE2EInstancesThroughConsole (instances_e2e_test.go) and // TestConfigE2EThroughConsole (config_e2e_test.go). // // Two deliberate choices, both worth stating: // // 1. The verb runs IN-PROCESS (runDeploy), not as a subprocess of the // built binary. The watch leg has to be pointed at a loopback stand-in // for the edge, which needs the watchOrigin seam (deploy.go) — an // unexported package var a subprocess could not reach without turning // it into an operator-visible env knob, which is exactly the thing that // seam's comment refuses. This is the same in-process shape the other // two console e2es use. // 2. The credential-isolation rule that governs the subprocess e2es // (carlosTestEnv + cmd.Dir, e2e_test.go:66 — a logged-in laptop running // `go test ./...` once minted 40+ accounts on the production console) is // honoured here by its in-process equivalent, useTempCredentials // (auth_test.go): it points CARLOS_CREDENTIALS, HOME, CARLOS_CONSOLE // *and* the working directory (t.Chdir — the .carlos/config ancestor // walk is why cmd.Dir exists) at scratch space, which is the same four // inputs carlosTestEnv+cmd.Dir masks for a subprocess. Nothing in this // test can reach the operator's real login, config, console or bucket. package main import ( "context" "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" "testing" "time" "github.com/carlosframework/platform/internal/console" "github.com/carlosframework/platform/internal/edge" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // assertServes checks that the edge under test answers "/" with the expected // site content AND stamps the expected version — the pair is the point: a // header that agreed with nothing on disk would be the false "verified" the // whole feature exists to prevent. func assertServes(t *testing.T, origin, wantBody, wantVersion string) { t.Helper() resp, err := http.Get(origin + "/") if err != nil { t.Fatalf("GET %s/: %v", origin, err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } if resp.StatusCode != http.StatusOK || string(body) != wantBody { t.Fatalf("GET %s/ = %d %q, want 200 %q", origin, resp.StatusCode, body, wantBody) } if got := resp.Header.Get("X-Carlos-Version"); got != wantVersion { t.Fatalf("X-Carlos-Version = %q, want %q (the header must name the bytes it served)", got, wantVersion) } } // TestE2EStaticDeployThroughConsole is the same verb for the class that has // no instance records at all (issue #112): a static site, served by the edge // off the channel pointer, never adopted. Nothing can be resolved from // instances here, so the deploy takes its host from -host and its channel // from the app's own channels — and the watch works at all only because the // static handler now stamps X-Carlos-Version (internal/edge/static.go). func TestE2EStaticDeployThroughConsole(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) const ( addr = "paul@keymail.dev" app = "sitely" channel = "canary/site" host = "sitely.example.test" ) tmp := t.TempDir() relStore := release.NewDirStore(filepath.Join(tmp, "store")) seed, pubB64, err := release.GenerateKey() if err != nil { t.Fatal(err) } priv, err := release.ParseSigningKey(seed, "e2e") if err != nil { t.Fatal(err) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } cst, err := console.Open(filepath.Join(tmp, "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { cst.Close() }) var srv *console.Server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) })) t.Cleanup(ts.Close) srv, err = console.NewServer(console.Config{ Origin: ts.URL, Keymail: "http://keymail.invalid", Registry: filepath.Join(tmp, "registry.db"), Releases: relStore, ReleasePub: pub, Key: priv, AppScheme: "http", AppsDomain: "oncarlos.test", }, cst) if err != nil { t.Fatal(err) } if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { t.Fatal(err) } if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil { t.Fatal(err) } token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) if err := credFile.save(); err != nil { t.Fatal(err) } // The site's edge is the REAL one: an edge.Edge with static serving // enabled over the same store and pubkey the console writes through, and // its static route pointed at the same channel. So the header the watch // judges is the one internal/edge/static.go actually stamps — the two // halves of #112 meeting, rather than each being tested against a mock of // the other. The registry here is the EDGE's own, in the test's temp dir; // checkNoRegistry still guards the one the CLI must never open. // // SetStaticRecheck(0) collapses the recheck window: the window is real in // production (and is what the muted watch line is about) but waiting one // out here would buy nothing but a minute per run. edgeReg, err := registry.Open(filepath.Join(tmp, "edge-registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { edgeReg.Close() }) ed, err := edge.New(edgeReg, nil) if err != nil { t.Fatal(err) } ed.EnableStatic(relStore, pub, filepath.Join(tmp, "edge-cache")) ed.SetStaticRecheck(0) edgeSrv := httptest.NewServer(ed) t.Cleanup(edgeSrv.Close) // The edge matches on the request's Host with the port stripped, and the // watch dials this loopback server, so the route's host is 127.0.0.1 — // the public name lives in -host, which is what gets printed. if err := edgeReg.Upsert(registry.Route{ Host: "127.0.0.1", Kind: "static", App: app, Channel: channel, Status: "ready", }); err != nil { t.Fatal(err) } if err := ed.Reload(); err != nil { t.Fatal(err) } origOrigin := watchOrigin watchOrigin = func(h string) string { if h != host { t.Errorf("watch dialled an unexpected host %q, want %q", h, host) } return edgeSrv.URL } t.Cleanup(func() { watchOrigin = origOrigin }) siteDir := filepath.Join(tmp, "site") if err := os.MkdirAll(siteDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { t.Fatal(err) } // --- run 1: the app has no channels yet, so -channel names one -------- buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", "-channel", channel, "-host", host, "-timeout", "30s", siteDir}) out := buf.String() for _, wantLine := range []string{ "deploying https://" + host, "shipped " + app + " sha0001", "promoted " + app + " sha0001 -> " + channel, "re-checks its channel", "live https://" + host, } { if !strings.Contains(out, wantLine) { t.Fatalf("static deploy stdout missing %q:\n%s", wantLine, out) } } if strings.Contains(out, "watching "+host) { t.Fatalf("static note still repeats the host the deploying line just printed:\n%s", out) } // A static route has no process and no /api/version, so the no-probe // advisory (deploy.go, gated on !staticDeploy) must not tell the // operator to set one — following that advice on a static site sets a // probe that 404s and fails every later `carlos deploy` for it. if strings.Contains(out, "build-probe") { t.Fatalf("static deploy suggested a build probe, which a static route can never serve:\n%s", out) } ptr, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, app, channel) if err != nil { t.Fatalf("channel after deploy: %v", err) } if ptr.Version != "sha0001" { t.Fatalf("%s points at %q, want sha0001", channel, ptr.Version) } m, err := release.GetManifest(context.Background(), relStore, release.OpsAccount, app, "sha0001") if err != nil { t.Fatalf("manifest after deploy: %v", err) } if m.Kind != "static" { t.Fatalf("manifest kind = %q, want static", m.Kind) } // The bytes and the stamp agree, straight off the real handler: "live" // meant the site tree, not a header floating free of any content. assertServes(t, edgeSrv.URL, "v1", "sha0001") // --- run 2: no -channel at all. The app now has exactly one channel, so // deploy resolves it from the channels API — the instance-less // equivalent of resolving from an instance record. if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v2"), 0o644); err != nil { t.Fatal(err) } buf.Reset() runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", "-host", host, "-timeout", "30s", siteDir}) out = buf.String() if !strings.Contains(out, "promoted "+app+" sha0002 -> "+channel) { t.Fatalf("channel was not resolved from the app's one channel:\n%s", out) } if !strings.Contains(out, "live https://"+host) { t.Fatalf("static deploy did not confirm the URL:\n%s", out) } if strings.Contains(out, edgeSrv.URL) { t.Fatalf("deploy printed the test's loopback origin instead of the public URL:\n%s", out) } assertServes(t, edgeSrv.URL, "v2", "sha0002") checkNoRegistry() } // TestE2EDeployThroughConsole is the full verb: one command takes an // unshipped binary to a URL that serves it. func TestE2EDeployThroughConsole(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) const ( addr = "paul@keymail.dev" app = "titogo" channel = "canary/e2e" version = "abc1234" label = "e2e rollout" ) tmp := t.TempDir() relStore := release.NewDirStore(filepath.Join(tmp, "store")) seed, pubB64, err := release.GenerateKey() if err != nil { t.Fatal(err) } priv, err := release.ParseSigningKey(seed, "e2e") if err != nil { t.Fatal(err) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } cst, err := console.Open(filepath.Join(tmp, "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { cst.Close() }) var srv *console.Server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) })) t.Cleanup(ts.Close) srv, err = console.NewServer(console.Config{ Origin: ts.URL, Keymail: "http://keymail.invalid", // never dialled — see e2eDeviceLogin Registry: filepath.Join(tmp, "registry.db"), Releases: relStore, ReleasePub: pub, Key: priv, AppScheme: "http", AppsDomain: "oncarlos.test", }, cst) if err != nil { t.Fatal(err) } if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { t.Fatal(err) } if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil { t.Fatal(err) } token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) if err := credFile.save(); err != nil { t.Fatal(err) } // One instance, on a channel of its own: deploy resolves the promote // target from THIS record, so nothing in the command line below names a // channel at all. host := app + ".bab.oncarlos.test" buf := captureStdout(t) runInstancesEnable([]string{"-app", app, "-account", "ops", "-channel", channel}) runInstancesCreate([]string{"-app", app, "-account", "ops", "-host", host}) rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, host) if err != nil { t.Fatalf("record after create: %v", err) } if rec.Channel != channel { t.Fatalf("record channel = %q, want %q", rec.Channel, channel) } // The instance's edge, on loopback. It serves the PREVIOUS version to // the first poll and the new one after that — so the watch leg is proven // to keep polling rather than passing on whatever the first response // happened to be, which is the whole reason the verb waits at all. var mu sync.Mutex polls := 0 probes := []string{} edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mu.Lock() polls++ probes = append(probes, r.URL.RawQuery) served := "old0000" if polls > 1 { served = version // the adopt pass "landed" between polls } mu.Unlock() w.Header().Set("X-Carlos-Version", served) })) t.Cleanup(edgeSrv.Close) // The seam (deploy.go): production dials https://; here the one // host this deploy watches resolves to the loopback edge above. Any // other host would be the verb watching something it was not asked to. origOrigin := watchOrigin watchOrigin = func(h string) string { if h != host { t.Errorf("watch dialled an unexpected host %q, want %q", h, host) } return edgeSrv.URL } t.Cleanup(func() { watchOrigin = origOrigin }) artifact := filepath.Join(tmp, "titogo-linux-arm64") if err := os.WriteFile(artifact, []byte("#!/bin/true\n"), 0o755); err != nil { t.Fatal(err) } // --- the verb ------------------------------------------------------ buf.Reset() runDeploy([]string{"-app", app, "-account", "ops", "-version", version, "-label", label, "-timeout", "30s", artifact}) out := buf.String() // Ship happened, against the bucket, signed — and --label, wired // straight through to runShipAPI (deploy.go), landed in the manifest // rather than the silently-passed "" this branch exists to fix // (discussion #13). m, err := release.GetManifest(context.Background(), relStore, release.OpsAccount, app, version) if err != nil { t.Fatalf("manifest after deploy: %v", err) } if m.Version != version || m.Kind != "binary" { t.Fatalf("manifest = %+v, want %s binary", m, version) } if m.Label != label { t.Fatalf("manifest label = %q, want %q — carlos deploy --label is not reaching the release", m.Label, label) } // Promote happened, onto the channel the RECORD named — never typed. ptr, err := release.Channel(context.Background(), relStore, pub, release.OpsAccount, app, channel) if err != nil { t.Fatalf("channel after deploy: %v", err) } if ptr.Version != version { t.Fatalf("%s points at %q, want %q", channel, ptr.Version, version) } // And the watch waited for the edge rather than trusting the promote: // the first poll's answer was the old version. mu.Lock() gotPolls, gotProbes := polls, append([]string{}, probes...) mu.Unlock() if gotPolls < 2 { t.Fatalf("edge polled %d times — the watch passed on a stale answer", gotPolls) } for _, q := range gotProbes { if !strings.Contains(q, "carlos-deploy-probe=") { t.Fatalf("poll without the cache-bust param: %v", gotProbes) } } // The three lines an operator reads, in order, and the "live" line names // the PUBLIC url — not the loopback origin the watch happened to dial. for _, want := range []string{ "deploying https://" + host, "shipped " + app + " " + version, "promoted " + app + " " + version + " -> " + channel, "live https://" + host, } { if !strings.Contains(out, want) { t.Fatalf("deploy stdout missing %q:\n%s", want, out) } } if i, j := strings.Index(out, "shipped"), strings.Index(out, "promoted"); i > j { t.Fatalf("promoted printed before shipped:\n%s", out) } if i, j := strings.Index(out, "deploying https://"), strings.Index(out, "shipped"); i < 0 || i > j { t.Fatalf("deploying line missing or printed after shipped:\n%s", out) } if strings.Contains(out, edgeSrv.URL) { t.Fatalf("deploy printed the test's loopback origin instead of the public URL:\n%s", out) } // A re-run of the same version is the CI-retry case: ship is a no-op, // promote is a no-op, and the verb still exits 0 with the URL confirmed // (the edge already serves it, so the first poll passes). buf.Reset() runDeploy([]string{"-app", app, "-account", "ops", "-version", version, "-timeout", "30s", artifact}) rerun := buf.String() if !strings.Contains(rerun, "already shipped") || !strings.Contains(rerun, "live https://"+host) { t.Fatalf("re-run of an identical deploy:\n%s", rerun) } // Nothing here may have opened a local registry: this is API mode. checkNoRegistry() } // TestE2EStaticDeployMintsPlatformHost is #186's static half, end to end: an // app with no route and no -host gets one. The console, the edge and the // bucket are all real, so what this proves is not "the CLI sent a request" // but that the record the mint writes is the record the edge serves from. // // The member never names a hostname. That is the whole feature: DNS under // the apps domain already resolves to the edge and the edge already derives // its own wildcard cert, so a record was the only missing piece. func TestE2EStaticDeployMintsPlatformHost(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) const ( addr = "paul@keymail.dev" app = "freshly" channel = "canary/site" // What the mint must derive on its own: the app's label under the // account's platform subdomain, ops being "bab". wantHost = app + ".bab.oncarlos.test" ) tmp := t.TempDir() relStore := release.NewDirStore(filepath.Join(tmp, "store")) seed, pubB64, err := release.GenerateKey() if err != nil { t.Fatal(err) } priv, err := release.ParseSigningKey(seed, "e2e") if err != nil { t.Fatal(err) } pub, err := release.ParsePublicKey(pubB64, "e2e") if err != nil { t.Fatal(err) } cst, err := console.Open(filepath.Join(tmp, "console.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { cst.Close() }) var srv *console.Server ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv.ServeHTTP(w, r) })) t.Cleanup(ts.Close) srv, err = console.NewServer(console.Config{ Origin: ts.URL, Keymail: "http://keymail.invalid", Registry: filepath.Join(tmp, "registry.db"), Releases: relStore, ReleasePub: pub, Key: priv, AppScheme: "http", AppsDomain: "oncarlos.test", }, cst) if err != nil { t.Fatal(err) } if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { t.Fatal(err) } if _, err := cst.CreateAppIn(console.OpsAccountID, app, addr, time.Now()); err != nil { t.Fatal(err) } token, expiresAt := e2eDeviceLogin(t, ts.URL, cst, addr, "e2e@laptop") credFile, err := loadCredentials() if err != nil { t.Fatal(err) } credFile.put(credential{Console: ts.URL, Address: addr, Token: token, ExpiresAt: expiresAt}) if err := credFile.save(); err != nil { t.Fatal(err) } edgeReg, err := registry.Open(filepath.Join(tmp, "edge-registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { edgeReg.Close() }) ed, err := edge.New(edgeReg, nil) if err != nil { t.Fatal(err) } ed.EnableStatic(relStore, pub, filepath.Join(tmp, "edge-cache")) ed.SetStaticRecheck(0) edgeSrv := httptest.NewServer(ed) t.Cleanup(edgeSrv.Close) // Standing in for the reconcile pass that materializes the record on a // box: the same route, on the loopback host the watch dials. if err := edgeReg.Upsert(registry.Route{ Host: "127.0.0.1", Kind: "static", App: app, Channel: channel, Status: "ready", }); err != nil { t.Fatal(err) } if err := ed.Reload(); err != nil { t.Fatal(err) } origOrigin := watchOrigin watchOrigin = func(h string) string { if h != wantHost { t.Errorf("watch dialled %q, want the MINTED host %q", h, wantHost) } return edgeSrv.URL } t.Cleanup(func() { watchOrigin = origOrigin }) siteDir := filepath.Join(tmp, "site") if err := os.MkdirAll(siteDir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello"), 0o644); err != nil { t.Fatal(err) } buf := captureStdout(t) runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", "-channel", channel, "-timeout", "30s", siteDir}) out := buf.String() for _, wantLine := range []string{ "minted " + wantHost, "deploying https://" + wantHost, "promoted " + app + " sha0001 -> " + channel, "live https://" + wantHost, } { if !strings.Contains(out, wantLine) { t.Fatalf("mint deploy stdout missing %q:\n%s", wantLine, out) } } // The record is the deliverable: a box's reconcile pass mints the route // from THIS, so the kind and the channel are what decide whether the URL // ever serves anything. rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, app, wantHost) if err != nil { t.Fatalf("record after mint: %v", err) } if rec.Kind != "static" { t.Fatalf("record Kind = %q, want static", rec.Kind) } if rec.Channel != channel { t.Fatalf("record Channel = %q, want %q — an omitted channel would have written stable", rec.Channel, channel) } assertServes(t, edgeSrv.URL, "hello", "sha0001") // A REDEPLOY must not mint again: the record is now in the instance // list, so the host resolves from it. if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("hello2"), 0o644); err != nil { t.Fatal(err) } buf.Reset() runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", "-timeout", "30s", siteDir}) out = buf.String() if strings.Contains(out, "minted") { t.Fatalf("redeploy minted a second time:\n%s", out) } if !strings.Contains(out, "live https://"+wantHost) { t.Fatalf("redeploy did not resolve the host from the record:\n%s", out) } assertServes(t, edgeSrv.URL, "hello2", "sha0002") checkNoRegistry() }