// End-to-end acceptance test for issue #66: `carlos instances // enable/create/list/delete` working through the console API with `carlos // auth login` alone — the last step between "promoted" and "reachable" // without a box shell or bucket credentials. // // Follows TestE2ECLIThroughConsole's shape (auth_e2e_test.go): a REAL // console.Server over a DirStore, a device-flow login landing a real bearer // token in a temp credential file, then — with NO store env set — the real // run* functions driven in-process. Ground truth is the DirStore's own // manifest and record objects, plus the `stdout` seam's confirmation lines // (these verbs print through cli.go's stdout var, so captureStdout reaches // them). package main import ( "context" "fmt" "net/http" "net/http/httptest" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/console" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) func TestE2EInstancesThroughConsole(t *testing.T) { useTempCredentials(t) clearDeploymentEnv(t) checkNoRegistry := requireNoRegistryTouch(t) 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() }) // The -addr / set-upstreams leg below states upstreams, which only a // platform operator may do. if err := cst.SetOperator("paul@keymail.dev", true); err != nil { t.Fatal(err) } 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) } const addr = "paul@keymail.dev" if err := cst.AddMember(console.OpsAccountID, addr, console.RoleOwner, time.Now()); err != nil { t.Fatal(err) } if _, err := cst.CreateAppIn(console.OpsAccountID, "titogo", 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) } // --- enable: the console pins the domain to the account's platform // subdomain; no -domain crosses the wire --------------------------------- const domain = "bab.oncarlos.test" buf := captureStdout(t) runInstancesEnable([]string{"-app", "titogo", "-account", "ops", "-channel", "beta"}) if want := "enabled instances for titogo under " + domain; !strings.Contains(buf.String(), want) { t.Fatalf("enable stdout = %q, want %q", buf.String(), want) } m, err := provision.GetManifest(context.Background(), relStore, release.OpsAccount, "titogo") if err != nil { t.Fatalf("manifest after enable: %v", err) } if m.Domain != domain || m.Channel != "beta" || m.Pool != "" { t.Fatalf("manifest = %+v, want %s on beta with the shared pool", m, domain) } // --- create: the app's own platform host, one label under the pin ------ host := "titogo." + domain buf.Reset() runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", host}) if want := "created instance " + host + " (channel beta)"; !strings.Contains(buf.String(), want) { t.Fatalf("create stdout = %q, want %q", buf.String(), want) } rec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host) if err != nil { t.Fatalf("record after create: %v", err) } if rec.Channel != "beta" || rec.Slug != "titogo" || rec.CreatedBy != addr { t.Fatalf("record = %+v, want beta/titogo created by %s", rec, addr) } // --- create -kind static: the flag reaches the record ------------------ // The shape `carlos deploy` mints for a static app's platform host. Here // only to prove the FLAG is wired to the wire field the door has always // read; the door's own acceptance of it is pinned console-side. staticHost := "site." + domain buf.Reset() runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", staticHost, "-kind", "static"}) staticRec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", staticHost) if err != nil { t.Fatalf("record after create --kind static: %v", err) } if staticRec.Kind != "static" { t.Fatalf("record = %+v, want kind static", staticRec) } // --- set-channel: the record is where a route's channel lives ---------- buf.Reset() runInstancesSetChannel([]string{"-app", "titogo", "-account", "ops", "-host", host, "-channel", "edge"}) if want := "moved " + host + " to edge"; !strings.Contains(buf.String(), want) { t.Fatalf("set-channel stdout = %q, want %q", buf.String(), want) } moved, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host) if err != nil { t.Fatalf("record after set-channel: %v", err) } if moved.Channel != "edge" { t.Fatalf("record channel = %q, want edge", moved.Channel) } // And back, so the rest of this test still sees the channel it set up. buf.Reset() runInstancesSetChannel([]string{"-app", "titogo", "-account", "ops", "-host", host, "-channel", "beta"}) // --- list ---------------------------------------------------------------- buf.Reset() runInstancesList([]string{"-app", "titogo", "-account", "ops"}) if out := buf.String(); !strings.Contains(out, host) || !strings.Contains(out, "beta") { t.Fatalf("list stdout = %q, want the record's host and channel", out) } // --- delete: idempotent record retirement ------------------------------- buf.Reset() runInstancesDelete([]string{"-app", "titogo", "-account", "ops", "-host", host}) if want := "deleted instance " + host; !strings.Contains(buf.String(), want) { t.Fatalf("delete stdout = %q, want %q", buf.String(), want) } if _, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", host); err == nil { t.Fatalf("record survived delete") } // --- create with -addr/-health-*, then set-upstreams (Task 8) ---------- haHost := "ha." + domain buf.Reset() runInstancesCreate([]string{"-app", "titogo", "-account", "ops", "-host", haHost, "-addr", "10.0.0.1:8090,10.0.0.2:8090", "-health-path", "/healthz", "-health-status", "204"}) if want := "created instance " + haHost; !strings.Contains(buf.String(), want) { t.Fatalf("create --addr stdout = %q, want %q", buf.String(), want) } haRec, err := provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost) if err != nil { t.Fatalf("record after create --addr: %v", err) } if len(haRec.Addrs) != 2 || haRec.Addrs[0] != "10.0.0.1:8090" || haRec.Addrs[1] != "10.0.0.2:8090" { t.Fatalf("record.Addrs = %v, want the two --addr entries", haRec.Addrs) } if haRec.Health == nil || haRec.Health.Path != "/healthz" || haRec.Health.Status != 204 { t.Fatalf("record.Health = %+v, want {Path: /healthz, Status: 204}", haRec.Health) } // list must show the UPSTREAMS column, "-" here since no box has // reported this fleet-fresh host through the status join yet. buf.Reset() runInstancesList([]string{"-app", "titogo", "-account", "ops"}) listOut := buf.String() if !strings.Contains(listOut, "UPSTREAMS") { t.Fatalf("list stdout = %q, want an UPSTREAMS column header", listOut) } if !strings.Contains(listOut, haHost) { t.Fatalf("list stdout = %q, want the multi-upstream host listed", listOut) } // set-upstreams repoints the whole set and probe in one PATCH. buf.Reset() runInstancesSetUpstreams([]string{"-app", "titogo", "-account", "ops", "-host", haHost, "-addrs", "10.0.0.3:8090", "-health-path", "/up", "-health-status", "200"}) if want := "updated upstreams for " + haHost; !strings.Contains(buf.String(), want) { t.Fatalf("set-upstreams stdout = %q, want %q", buf.String(), want) } haRec, err = provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost) if err != nil { t.Fatalf("record after set-upstreams: %v", err) } if len(haRec.Addrs) != 1 || haRec.Addrs[0] != "10.0.0.3:8090" { t.Fatalf("record.Addrs after set-upstreams = %v, want [10.0.0.3:8090]", haRec.Addrs) } if haRec.Health == nil || haRec.Health.Path != "/up" || haRec.Health.Status != 200 { t.Fatalf("record.Health after set-upstreams = %+v, want {Path: /up, Status: 200}", haRec.Health) } // set-upstreams naming NEITHER -addrs nor a -health-* flag reaches the // console's own refusal, surfaced verbatim rather than a client-side // message that would paraphrase it (self-review rule this task carries). // That path is log.Fatal, same as every other malformed-invocation path // in this file (instances_cli_test.go's own doc comment: no argv test // harness exists here for os.Exit paths) — pinned instead at the client // layer, where the request/response round trip is actually observable: // TestInstanceSetUpstreamsSurfacesRefusalVerbatim (apimode_test.go) // proves instanceSetUpstreams returns the door's error text unmodified. // --- steering (Task 3): a top-level verb, not an "instances" sub-verb -- // haHost's record lives under this manifest's shared pool (Pool == "" // per the assertion at line 103-105 above), so it may opt into latency // steering and back off. buf.Reset() runSteering([]string{"-app", "titogo", "-account", "ops", "-host", haHost, "latency"}) if want := "steering set to latency for " + haHost; !strings.Contains(buf.String(), want) { t.Fatalf("steering stdout = %q, want %q", buf.String(), want) } haRec, err = provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost) if err != nil { t.Fatalf("record after steering latency: %v", err) } if haRec.Steering != "latency" { t.Fatalf("record.Steering after steering latency = %q, want latency", haRec.Steering) } // Everything set-upstreams already wrote onto this record is undisturbed // by a steering-only patch. if len(haRec.Addrs) != 1 || haRec.Addrs[0] != "10.0.0.3:8090" { t.Fatalf("steering patch moved addrs: %v", haRec.Addrs) } buf.Reset() runSteering([]string{"-app", "titogo", "-account", "ops", "-host", haHost, "off"}) if want := "steering turned off for " + haHost; !strings.Contains(buf.String(), want) { t.Fatalf("steering off stdout = %q, want %q", buf.String(), want) } haRec, err = provision.GetRecord(context.Background(), relStore, release.OpsAccount, "titogo", haHost) if err != nil { t.Fatalf("record after steering off: %v", err) } if haRec.Steering != "" { t.Fatalf("record.Steering after steering off = %q, want empty", haRec.Steering) } // The pool-conflict 409 (a record whose Pool != "") is a log.Fatal path // through this verb, same as set-upstreams' own refusal above — no argv // test harness exists here for os.Exit paths. It is proven at the console // door (TestAPIInstanceUpdateSteeringPoolConflict, internal/console) and // at this client's own layer (TestInstanceSetSteeringSurfacesRefusalVerbatim, // apimode_test.go), which is where the request/response round trip is // actually observable. // --- apps set (zero-downtime plan 6, Task 5), in the spec's order -------- buf.Reset() runAppsSet([]string{"titogo", "--swap", "hold", "-account", "ops"}) if want := fmt.Sprintf(appsSetDone, "titogo", "hold", 1); !strings.Contains(buf.String(), want) { t.Fatalf("apps set stdout = %q, want %q", buf.String(), want) } policy, err := appconfig.GetSwapPolicy(context.Background(), relStore, release.OpsAccount, "titogo") if err != nil || policy.Swap != appconfig.SwapHold { t.Fatalf("stored policy = %+v, %v", policy, err) } // --- instances retry (Task 11): host first, as the spec writes it -------- box := status.CollectBox(nil, status.Probes{MachineID: "e2e-box"}, time.Now()) box.Metrics = &status.BoxMetrics{Rollout: &status.RolloutReport{Hosts: map[string]status.HostRollout{ haHost: {Account: release.OpsAccount, App: "titogo", Installed: "v2", Serving: "v1", Good: "v1", Awake: true, HeldBy: "v2", HeldEpoch: 9, HeldBook: "e2e-book", Reason: status.ReasonHealth, RetryV: status.HostRetryV}, // the retry protocol this box takes (readiness round 2, Task 3) }}} if err := status.WriteBox(context.Background(), relStore, box); err != nil { t.Fatal(err) } buf.Reset() runInstancesRetry([]string{haHost, "-app", "titogo", "-account", "ops"}) if want := fmt.Sprintf(instancesRetryDone, haHost, "v2"); !strings.Contains(buf.String(), want) { t.Fatalf("instances retry stdout = %q, want %q", buf.String(), want) } // Grants ride the app's host-retry log, not restart-scope.json (Task 3). scopes, found, err := release.LoadHostRetries(context.Background(), relStore, release.OpsAccount, "titogo") if err != nil || !found || len(scopes.Requests) == 0 { t.Fatalf("host retries = %+v, %v, %v", scopes, found, err) } last := scopes.Requests[len(scopes.Requests)-1] if last.Scope.Kind != release.RestartScopeRetry || last.Scope.Value != haHost { t.Fatalf("last request = %+v", last) } // The entry carries the console's root-signed grant for the held build // at the epoch and book the box reported (Task 8, spec §11; final review // round 1, finding 3): the box clears nothing on an entry without one. 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" { t.Fatalf("signed grant = %+v, %v; want v2 at epoch 9 of book e2e-book", g, err) } // The whole flow ran through the console: nothing here may have touched // a local registry. checkNoRegistry() }