| new file mode 100644 |
| index 000000000..64a09c242 |
| --- /dev/null |
| +++ b/deploy_e2e_test.go |
| @@ -0,0 +1,231 @@ |
| +// 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" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "sync" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/console" |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// 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" |
| + ) |
| + |
| + 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://<host>; 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, "-timeout", "30s", artifact}) |
| + out := buf.String() |
| + |
| + // Ship happened, against the bucket, signed. |
| + 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) |
| + } |
| + // 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{ |
| + "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 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() |
| +} |