| index 6ea48fdab..3073ba395 100644 |
| --- a/deploy_test.go |
| +++ b/deploy_test.go |
| @@ -2,9 +2,15 @@ package main |
| |
| import ( |
| "bytes" |
| + "context" |
| + "encoding/json" |
| "fmt" |
| + "io" |
| "net/http" |
| "net/http/httptest" |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| "strings" |
| "sync" |
| "sync/atomic" |
| @@ -467,3 +473,351 @@ func TestWatchVersionTimeoutCarriesWakeReasonAndLogsHint(t *testing.T) { |
| t.Fatalf("empty reason printed a blank line: %v", err) |
| } |
| } |
| + |
| +// ---- domain attach (Task 6, #82/#151) -------------------------------- |
| +// |
| +// The server half of the wire contract below does not exist yet (Task 8 |
| +// builds it, gated on #151 landing); these tests pin what THIS client sends |
| +// and expects against a hand-rolled fake console, exactly like |
| +// TestAPISessionRestartWireShape (restart_test.go) does for a door that |
| +// already shipped. |
| + |
| +// TestAPISessionDomainListAndAttachWireShape pins the wire shape the |
| +// console door (Task 8) has to answer: GET .../domains for the list, POST |
| +// .../domains/{host} with a {"kind","channel"} body for attach, decoding a |
| +// 201 into an apiDomain. |
| +func TestAPISessionDomainListAndAttachWireShape(t *testing.T) { |
| + var gotListPath string |
| + var gotAttachPath, gotAttachMethod, gotAttachBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + switch { |
| + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/domains"): |
| + gotListPath = r.URL.RequestURI() |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "domains": []map[string]string{{"host": "existing.example.test", "route": "static", "dns": "pointing"}}, |
| + }) |
| + case r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/domains/"): |
| + gotAttachPath, gotAttachMethod = r.URL.RequestURI(), r.Method |
| + b, _ := io.ReadAll(r.Body) |
| + gotAttachBody = string(b) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]string{"host": "fresh.example.test", "route": "", "dns": ""}) |
| + default: |
| + http.NotFound(w, r) |
| + } |
| + })) |
| + defer ts.Close() |
| + |
| + sess := newTestSession(ts.URL) |
| + domains, err := sess.domainList(context.Background(), apiAcct{Seg: "ops"}, "sitely") |
| + if err != nil { |
| + t.Fatalf("domainList: %v", err) |
| + } |
| + if gotListPath != "/api/cli/apps/ops/sitely/domains" { |
| + t.Fatalf("list path = %s", gotListPath) |
| + } |
| + if len(domains) != 1 || domains[0].Host != "existing.example.test" || domains[0].DNS != "pointing" { |
| + t.Fatalf("domains = %+v", domains) |
| + } |
| + |
| + got, err := sess.domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely", |
| + "fresh.example.test", "static", "canary/live") |
| + if err != nil { |
| + t.Fatalf("domainAttach: %v", err) |
| + } |
| + if gotAttachMethod != http.MethodPost { |
| + t.Fatalf("method = %s, want POST", gotAttachMethod) |
| + } |
| + if gotAttachPath != "/api/cli/apps/ops/sitely/domains/fresh.example.test" { |
| + t.Fatalf("path = %s", gotAttachPath) |
| + } |
| + if !strings.Contains(gotAttachBody, `"kind":"static"`) || !strings.Contains(gotAttachBody, `"channel":"canary/live"`) { |
| + t.Fatalf("body = %s, want kind+channel", gotAttachBody) |
| + } |
| + if got.Host != "fresh.example.test" { |
| + t.Fatalf("attach result = %+v", got) |
| + } |
| +} |
| + |
| +// TestAPISessionDomainAttachRefusalSurfacesVerbatim pins the two refusal |
| +// shapes the wire contract names: 409 "host unavailable" (a host already |
| +// held — the binding note deploy's own gate exists to avoid ever |
| +// triggering) and a 422 policy refusal. Both must reach the caller in the |
| +// console's own words, via apiErrorFromBody, never rewritten. |
| +func TestAPISessionDomainAttachRefusalSurfacesVerbatim(t *testing.T) { |
| + for _, tc := range []struct { |
| + status int |
| + msg string |
| + }{ |
| + {http.StatusConflict, "host unavailable"}, |
| + {http.StatusUnprocessableEntity, "not a usable hostname"}, |
| + } { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(tc.status) |
| + json.NewEncoder(w).Encode(map[string]string{"error": tc.msg}) |
| + })) |
| + _, err := newTestSession(ts.URL).domainAttach(context.Background(), apiAcct{Seg: "ops"}, "sitely", |
| + "x.test", "static", "canary/live") |
| + ts.Close() |
| + if err == nil || !strings.Contains(err.Error(), tc.msg) { |
| + t.Fatalf("status %d: err = %v, want %q verbatim", tc.status, err, tc.msg) |
| + } |
| + } |
| +} |
| + |
| +// TestDomainNeedsAttach pins the gate itself, independent of any HTTP call: |
| +// a host answered by an existing instance record, or already named in |
| +// domainList, must never be attached again. |
| +func TestDomainNeedsAttach(t *testing.T) { |
| + recs := []apiInstanceRecord{{Host: "has-instance.test"}} |
| + domains := []apiDomain{{Host: "already-claimed.test"}} |
| + cases := []struct { |
| + host string |
| + want bool |
| + }{ |
| + {"fresh.test", true}, |
| + {"has-instance.test", false}, |
| + {"already-claimed.test", false}, |
| + } |
| + for _, c := range cases { |
| + if got := domainNeedsAttach(c.host, recs, domains); got != c.want { |
| + t.Errorf("domainNeedsAttach(%q) = %v, want %v", c.host, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// deployFakeConsole is a minimal console double for `carlos deploy`'s own |
| +// tests: only the doors deploy itself calls (instances, domains, releases, |
| +// promote) — not internal/console's real handlers, since the CLI-facing |
| +// domains door does not exist on this branch yet (Task 8 builds it, |
| +// integrating against #151's real one). calls records every hit, in order, |
| +// so a test can assert attach happened before ship rather than merely that |
| +// it happened at all. |
| +type deployFakeConsole struct { |
| + ts *httptest.Server |
| + |
| + mu sync.Mutex |
| + calls []string // "METHOD /path", in hit order |
| + domains []map[string]string |
| + shipVersion string |
| +} |
| + |
| +func newDeployFakeConsole(t *testing.T, shipVersion string) *deployFakeConsole { |
| + t.Helper() |
| + fc := &deployFakeConsole{shipVersion: shipVersion} |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("GET /api/cli/apps/{account}/{app}/instances", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + json.NewEncoder(w).Encode(map[string]any{"instances": []apiInstanceRecord{}}) |
| + }) |
| + mux.HandleFunc("GET /api/cli/apps/{account}/{app}/domains", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + fc.mu.Lock() |
| + domains := fc.domains |
| + fc.mu.Unlock() |
| + json.NewEncoder(w).Encode(map[string]any{"domains": domains}) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{account}/{app}/domains/{host}", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]string{"host": r.PathValue("host"), "route": "", "dns": ""}) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + fc.mu.Lock() |
| + ver := fc.shipVersion |
| + fc.mu.Unlock() |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]string{"version": ver}) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", func(w http.ResponseWriter, r *http.Request) { |
| + fc.record(r) |
| + json.NewEncoder(w).Encode(map[string]any{"result": "promoted", "held": false}) |
| + }) |
| + fc.ts = httptest.NewServer(mux) |
| + t.Cleanup(fc.ts.Close) |
| + return fc |
| +} |
| + |
| +func (fc *deployFakeConsole) record(r *http.Request) { |
| + fc.mu.Lock() |
| + defer fc.mu.Unlock() |
| + fc.calls = append(fc.calls, r.Method+" "+r.URL.Path) |
| +} |
| + |
| +// callIndex returns the index of the first recorded call containing sub, or |
| +// -1 — used both to assert a call happened and, via two calls, that one |
| +// happened before another. |
| +func (fc *deployFakeConsole) callIndex(sub string) int { |
| + fc.mu.Lock() |
| + defer fc.mu.Unlock() |
| + for i, c := range fc.calls { |
| + if strings.Contains(c, sub) { |
| + return i |
| + } |
| + } |
| + return -1 |
| +} |
| + |
| +// TestDeployAutoAttachesUnclaimedStaticHost pins the auto-attach behaviour |
| +// end to end: a -host deploy against a fresh static target (no instance |
| +// record, no domainList entry) attaches the host BEFORE shipping and says |
| +// so; a repeated deploy against the SAME host, once domainList already |
| +// names it, must not re-POST attach at all — the binding note's 409 "host |
| +// unavailable" is what a re-attach gets, so this is what keeps a repeated |
| +// `carlos deploy` idempotent rather than erroring on its own prior success. |
| +func TestDeployAutoAttachesUnclaimedStaticHost(t *testing.T) { |
| + useTempCredentials(t) |
| + clearDeploymentEnv(t) |
| + checkNoRegistry := requireNoRegistryTouch(t) |
| + |
| + const ( |
| + app = "sitely" |
| + host = "sitely.example.test" |
| + channel = "canary/site" |
| + ) |
| + fc := newDeployFakeConsole(t, "sha0001") |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", |
| + ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // The watch leg's own edge: always answers whatever version this run |
| + // expects, so the test is about the attach step, not about racing a |
| + // slow-to-converge edge. |
| + var edgeVersion atomic.Value |
| + edgeVersion.Store("sha0001") |
| + edgeSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("X-Carlos-Version", edgeVersion.Load().(string)) |
| + })) |
| + defer edgeSrv.Close() |
| + origOrigin := watchOrigin |
| + watchOrigin = func(h string) string { return edgeSrv.URL } |
| + defer func() { watchOrigin = origOrigin }() |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + buf := captureStdout(t) |
| + runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0001", |
| + "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) |
| + out := buf.String() |
| + |
| + attachIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains/" + host) |
| + if attachIdx < 0 { |
| + t.Fatalf("attach was never POSTed: %v", fc.calls) |
| + } |
| + if shipIdx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); shipIdx < attachIdx { |
| + t.Fatalf("ship (index %d) happened before attach (index %d): %v", shipIdx, attachIdx, fc.calls) |
| + } |
| + if want := "attached " + host + " (first route: static, channel " + channel + ")"; !strings.Contains(out, want) { |
| + t.Fatalf("stdout missing %q:\n%s", want, out) |
| + } |
| + |
| + // --- re-run: domainList now names the host, as the console would once |
| + // the first attach landed. The second deploy must NOT re-POST attach. -- |
| + fc.mu.Lock() |
| + fc.domains = []map[string]string{{"host": host, "route": "static", "dns": "pointing"}} |
| + fc.calls = nil |
| + fc.shipVersion = "sha0002" |
| + fc.mu.Unlock() |
| + edgeVersion.Store("sha0002") |
| + |
| + buf.Reset() |
| + runDeploy([]string{"-app", app, "-account", "ops", "-kind", "static", "-version", "sha0002", |
| + "-channel", channel, "-host", host, "-timeout", "5s", siteDir}) |
| + out = buf.String() |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains/" + host); idx >= 0 { |
| + t.Fatalf("a host already in domainList must not trigger an attach POST: %v", fc.calls) |
| + } |
| + if strings.Contains(out, "attached "+host) { |
| + t.Fatalf("stdout claims an attach that must not have happened:\n%s", out) |
| + } |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/releases"); idx < 0 { |
| + t.Fatalf("ship never happened on the re-run: %v", fc.calls) |
| + } |
| + |
| + checkNoRegistry() |
| +} |
| + |
| +// TestDeployExitsHonestlyWhenDNSIsNotPointingYet pins the exit contract for |
| +// a connection-class watch timeout: the deploy itself already succeeded |
| +// (shipped, promoted) but the host never answered at all — the shape a |
| +// domain that has not been pointed here yet produces — so the exit text |
| +// must say DNS specifically, carry the console's own guidance, and still |
| +// exit nonzero: "deploy failed" would be a lie (the release IS live), and a |
| +// zero exit would be a worse one (the URL does not serve it). |
| +// |
| +// Run as a subprocess (buildCarlos + exec.Command, carlosTestEnv's own |
| +// pattern): log.Fatal exits the process, which would take the rest of this |
| +// package's tests down with it if this ran in-process. The host is a bare |
| +// loopback address with nothing listening on :443 — an instant, hermetic |
| +// "unreachable" that needs no real DNS or outbound network access, and |
| +// exercises the exact diag class (watchVersion's "unreachable: ...", from |
| +// http.Client.Get itself failing) a not-yet-pointing domain produces. The |
| +// fake console already lists the host as claimed (domainList has an entry |
| +// with guidance), so auto-attach is a no-op here — this is the SECOND deploy |
| +// to an already-attached host shape, not the first. |
| +func TestDeployExitsHonestlyWhenDNSIsNotPointingYet(t *testing.T) { |
| + bin := buildCarlos(t) |
| + const ( |
| + app = "sitely" |
| + host = "127.0.0.1" |
| + channel = "canary/site" |
| + version = "sha0001" |
| + guide = "create a CNAME 127.0.0.1 -> sitely.oncarlos.test" |
| + ) |
| + fc := newDeployFakeConsole(t, version) |
| + fc.domains = []map[string]string{{"host": host, "route": "", "dns": "elsewhere", "guidance": guide}} |
| + |
| + scratch := t.TempDir() |
| + credPath := filepath.Join(scratch, "credentials") |
| + t.Setenv("CARLOS_CREDENTIALS", credPath) |
| + credFile, err := loadCredentials() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + credFile.put(credential{Console: fc.ts.URL, Address: "paul@keymail.dev", Token: "cli-token", |
| + ExpiresAt: time.Now().Add(time.Hour)}) |
| + if err := credFile.save(); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + env := append(os.Environ(), |
| + "CARLOS_CREDENTIALS="+credPath, |
| + "HOME="+scratch, |
| + "CARLOS_CONSOLE=", |
| + "CARLOS_DEPLOYMENT_BUCKET=", |
| + "CARLOS_DEPLOYMENT_DIR=", |
| + ) |
| + |
| + siteDir := t.TempDir() |
| + if err := os.WriteFile(filepath.Join(siteDir, "index.html"), []byte("v1"), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + cmd := exec.Command(bin, "deploy", "-app", app, "-account", "ops", "-kind", "static", |
| + "-version", version, "-channel", channel, "-host", host, "-console", fc.ts.URL, |
| + "-timeout", "100ms", siteDir) |
| + cmd.Env = env |
| + cmd.Dir = t.TempDir() // no .carlos/config ancestor — the repo rule (carlosTestEnv's own reasoning) |
| + out, err := cmd.CombinedOutput() |
| + if err == nil { |
| + t.Fatalf("DNS-not-pointing deploy exited 0:\n%s", out) |
| + } |
| + want := "DNS for " + host + " is not pointing at this deployment yet. Create: " + guide + |
| + ". Watch with 'carlos domains -app " + app + "'; re-run this deploy any time — the shipped release is already promoted." |
| + if !strings.Contains(string(out), want) { |
| + t.Fatalf("exit text = %q, want it to contain %q", out, want) |
| + } |
| + if idx := fc.callIndex("POST /api/cli/apps/ops/sitely/domains/" + host); idx >= 0 { |
| + t.Fatalf("host already in domainList must not have triggered an attach POST: %v", fc.calls) |
| + } |
| +} |