#162: role and home on the instance surface, CLI and docs
apiInstanceRecord (apimode.go) gains Role and Home, decoded straight off provision.Record's own fields riding the wire through instanceListRecord and instanceGetRecord — nothing to add there. printInstances is pulled out of runInstancesList's inline table loop so it is a plain, testable function over []apiInstanceRecord; it adds a ROLE column that prints Role verbatim (empty for a home, never the word "home"). instanceCreate's signature is left alone: it is called from deploy.go and canary.go, outside this task's file scope, and role/home cannot be threaded through it without touching those call sites. A replica's create is a distinct shape anyway (no --host, no --channel — the console mints the host and stamps the home's channel), so it gets its own client method, instanceCreateReplica, and its own branch in runInstancesCreate off --role/--home. validateReplicaCreateArgs is the local, pre-request refusal for the two flags (mirroring handleAPIInstanceCreate's own refusals), pulled out as a plain function because runInstancesCreate itself returns nothing and log.Fatals on a bad invocation like every other verb here. Spec §12 also asks for a CLI path onto the record's `replicas` opt-in, named nowhere yet: `carlos instances set-replicas`, over instanceSetReplicas (PATCH, addrs' nil-vs-empty-pointer split) added alongside set-channel and set-environment in cli.go's dispatch. docs/site/cli.md documents --role/--home, set-replicas, and a worked example in dependency order: create the home, steer it, set-replicas, then create the replica. TDD: apimode_test.go pins instanceCreateReplica's and instanceSetReplicas' wire contracts (role/home/no-host/no-channel; replicas' nil-omits/ empty-clears split); instances_cli_test.go pins printInstances' ROLE column and validateReplicaCreateArgs' two-way refusal. go test . ./internal/docsite/ ./internal/console/: PASS. GOOS=darwin and GOOS=windows both build.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
6 files changed,
+416
−20
apimode.go+84 −0apimode_test.go+109 −0cli.go+3 −0docs/site/cli.md+28 −3instances_cli.go+141 −17instances_cli_test.go+51 −0
CI passed — run details
diff --git a/apimode.go b/apimode.go| index 7e8d0cd6f..e8c931549 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -2824,6 +2824,17 @@ type apiInstanceRecord struct { |
| // STATIC record (#82's attach writes one) from a real instance — a |
| // static record is a static-deploy target, not an instance to adopt. |
| Kind string `json:"kind"` |
| + // Role and Home are the replica pair (#162, spec §12): Role is "" (a |
| + // serving home, the default every pre-#162 record and console decodes |
| + // to) or "replica" (a mirror of another instance, placed near its |
| + // buyers); Home is the public host a replica mirrors, empty for a home. |
| + // Both ride the wire on every record — provision.Record embeds them |
| + // straight through instanceListRecord and instanceGetRecord |
| + // (internal/console/apiinstances.go) — so a console that predates #162 |
| + // simply sends neither key and both decode to their zero value, exactly |
| + // as an old console's missing StagedVersion or Health does above. |
| + Role string `json:"role"` |
| + Home string `json:"home"` |
| // StagedVersion is the console's additive staged_version join (Task 8, |
| // issue #78): whichever version a box's adopt pass last reported for |
| // this host, via status/boxes/<label>.json. Fresh-ish for as long as |
| @@ -2936,6 +2947,50 @@ func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, r |
| return out, nil |
| } |
| +// instanceCreateReplica asks the console to create a REPLICA instance record |
| +// — `carlos instances create --role replica --home <host>` (#162, spec §12), |
| +// over the same POST /instances door instanceCreate uses. It is a separate |
| +// method rather than instanceCreate plus two more parameters because a |
| +// replica's shape is not instanceCreate's shape with extras: host and |
| +// channel are not this caller's to give at all (the console mints the host |
| +// and stamps the home's channel — handleAPIInstanceCreate refuses either if |
| +// sent, internal/console/apiinstances.go), so there is no "host" or |
| +// "channel" parameter here for a caller to misuse. |
| +// |
| +// home is the public host of the record this one mirrors; region is the |
| +// placement it is created in, subject to the console's cross-record rails |
| +// (the home must exist, be latency-steered, and have opted this placement |
| +// into Replicas — instanceSetReplicas is the door for that opt-in). kind, |
| +// environment, addrs and probe are the same optional route-shape fields |
| +// instanceCreate accepts, passed straight through: a replica's Kind and |
| +// Environment are not implied by its home and default the same way a plain |
| +// create's do. |
| +func (s *apiSession) instanceCreateReplica(ctx context.Context, a apiAcct, app, home, region, kind, environment string, addrs []string, probe *instanceProbeReq, messages ...string) (apiInstanceRecord, error) { |
| + message := firstMutationMessage(messages) |
| + in := struct { |
| + Region string `json:"region,omitempty"` |
| + Kind string `json:"kind,omitempty"` |
| + Environment string `json:"environment,omitempty"` |
| + Addrs []string `json:"addrs,omitempty"` |
| + Probe *instanceProbeReq `json:"probe,omitempty"` |
| + Role string `json:"role"` |
| + Home string `json:"home"` |
| + Message string `json:"message,omitempty"` |
| + }{region, kind, environment, addrs, probe, "replica", home, message} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in) |
| + if err != nil { |
| + return apiInstanceRecord{}, err |
| + } |
| + if status != http.StatusCreated { |
| + return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiInstanceRecord |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiInstanceRecord{}, fmt.Errorf("instances create --role replica: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| // instanceSetChannel moves one route onto a different release channel — |
| // `carlos instances set-channel`, the CLI front over |
| // handleAPIInstanceChannel. A gated move comes back as a 202 the same way a |
| @@ -3057,6 +3112,35 @@ func (s *apiSession) instanceSetSteering(ctx context.Context, a apiAcct, app, ho |
| return out, nil |
| } |
| +// instanceSetReplicas repoints one HOME instance's replica opt-in — the set |
| +// of placement codes its account has agreed its data may be mirrored into |
| +// (#162, spec §12; `carlos instances set-replicas`), over the same PATCH |
| +// /instances/<host> door set-upstreams and set-steering use. |
| +// |
| +// replicas is a *[]string for the reason instanceSetUpstreams' addrs is: nil |
| +// omits the "replicas" key entirely, leaving the opt-in alone (the flag was |
| +// not given), while a non-nil pointer — including one to an empty slice — is |
| +// a real request that crosses the wire, since there is no bare word this |
| +// verb can send instead to mean "leave it alone". An explicit empty list |
| +// withdraws every placement. |
| +func (s *apiSession) instanceSetReplicas(ctx context.Context, a apiAcct, app, host string, replicas *[]string) (apiInstanceRecord, error) { |
| + in := struct { |
| + Replicas *[]string `json:"replicas,omitempty"` |
| + }{replicas} |
| + status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in) |
| + if err != nil { |
| + return apiInstanceRecord{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiInstanceRecord |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiInstanceRecord{}, fmt.Errorf("instances set-replicas: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| // instanceSetGate sets ("" clears) one instance's password gate through |
| // the console, which hashes it; the plaintext crosses the wire once, over |
| // TLS. A console older than the field answers 400 "nothing to update", |
diff --git a/apimode_test.go b/apimode_test.go| index 681094f26..ea231d587 100644 |
| --- a/apimode_test.go |
| +++ b/apimode_test.go |
| @@ -3836,3 +3836,112 @@ func TestAPISessionInstanceSetChannel(t *testing.T) { |
| } |
| }) |
| } |
| + |
| +// TestInstanceCreateReplicaSendsRoleAndHome pins instanceCreateReplica's |
| +// contract (#162, spec §12): it POSTs the same /instances door |
| +// instanceCreate uses, carrying role "replica" and the caller's --home |
| +// verbatim, with no host and no channel on the wire — the console mints the |
| +// host and stamps the home's channel, and a caller-supplied value of either |
| +// is refused by the door (internal/console/apiinstances.go, |
| +// handleAPIInstanceCreate). The response's Host is whatever the console |
| +// minted; this client does not choose it. |
| +func TestInstanceCreateReplicaSendsRoleAndHome(t *testing.T) { |
| + var gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodPost { |
| + t.Errorf("method = %s, want POST", r.Method) |
| + } |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "host": "r7x2k9m4qa.go.tito.io", "channel": "stable", "role": "replica", "home": "acme.go.tito.io", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + rec, err := s.instanceCreateReplica(context.Background(), apiAcct{Seg: "bah"}, "app", |
| + "acme.go.tito.io", "uk", "", "", nil, nil) |
| + if err != nil { |
| + t.Fatalf("instanceCreateReplica: %v", err) |
| + } |
| + if rec.Host != "r7x2k9m4qa.go.tito.io" || rec.Role != "replica" || rec.Home != "acme.go.tito.io" { |
| + t.Fatalf("rec = %+v", rec) |
| + } |
| + var got struct { |
| + Host string `json:"host"` |
| + Channel string `json:"channel"` |
| + Role string `json:"role"` |
| + Home string `json:"home"` |
| + Region string `json:"region"` |
| + } |
| + if err := json.Unmarshal([]byte(gotBody), &got); err != nil { |
| + t.Fatalf("request body unmarshal: %v\nbody: %s", err, gotBody) |
| + } |
| + if got.Role != "replica" || got.Home != "acme.go.tito.io" || got.Region != "uk" { |
| + t.Fatalf("request body = %+v, want role replica, home acme.go.tito.io, region uk", got) |
| + } |
| + if strings.Contains(gotBody, `"host"`) { |
| + t.Fatalf("request body = %q, must not send a host — the console mints a replica's host", gotBody) |
| + } |
| + if strings.Contains(gotBody, `"channel"`) { |
| + t.Fatalf("request body = %q, must not send a channel — a replica runs its home's channel", gotBody) |
| + } |
| +} |
| + |
| +// TestInstanceSetReplicasPatches pins instanceSetReplicas' contract (#162, |
| +// spec §12): it PATCHes the same /instances/<host> door set-upstreams and |
| +// set-steering use, and replicas is a *[]string with the same nil-vs-empty |
| +// split addrs carries — nil omits the key entirely ("leave the opt-in |
| +// alone"), and a non-nil pointer, empty included, is a real request that |
| +// crosses the wire as an explicit "replicas" key. |
| +func TestInstanceSetReplicasPatches(t *testing.T) { |
| + var gotMethod, gotPath, gotBody string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotMethod, gotPath = r.Method, r.URL.Path |
| + b, _ := io.ReadAll(r.Body) |
| + gotBody = string(b) |
| + json.NewEncoder(w).Encode(map[string]any{"host": "acme.go.tito.io"}) |
| + })) |
| + defer ts.Close() |
| + |
| + s := newTestSession(ts.URL) |
| + replicas := []string{"uk", "ie"} |
| + rec, err := s.instanceSetReplicas(context.Background(), apiAcct{Seg: "bah"}, "app", "acme.go.tito.io", &replicas) |
| + if err != nil { |
| + t.Fatalf("instanceSetReplicas: %v", err) |
| + } |
| + if rec.Host != "acme.go.tito.io" { |
| + t.Fatalf("rec = %+v", rec) |
| + } |
| + if gotMethod != http.MethodPatch { |
| + t.Fatalf("method = %s, want PATCH", gotMethod) |
| + } |
| + if gotPath != "/api/cli/apps/bah/app/instances/acme.go.tito.io" { |
| + t.Fatalf("path = %q", gotPath) |
| + } |
| + if gotBody != `{"replicas":["uk","ie"]}` { |
| + t.Fatalf("request body = %q, want a bare replicas key", gotBody) |
| + } |
| + |
| + // nil (flag not given): no "replicas" key at all — "leave it alone". |
| + gotBody = "" |
| + if _, err := s.instanceSetReplicas(context.Background(), apiAcct{Seg: "bah"}, "app", "acme.go.tito.io", nil); err != nil { |
| + t.Fatalf("instanceSetReplicas (nil): %v", err) |
| + } |
| + if strings.Contains(gotBody, "replicas") { |
| + t.Fatalf("request body = %q, want no replicas key for a nil pointer", gotBody) |
| + } |
| + |
| + // &[]string{} (an explicit empty --replicas): the wire's withdraw-every- |
| + // placement spelling, "replicas":[]. |
| + gotBody = "" |
| + empty := []string{} |
| + if _, err := s.instanceSetReplicas(context.Background(), apiAcct{Seg: "bah"}, "app", "acme.go.tito.io", &empty); err != nil { |
| + t.Fatalf("instanceSetReplicas (empty): %v", err) |
| + } |
| + if !strings.Contains(gotBody, `"replicas":[]`) { |
| + t.Fatalf("request body = %q, want an explicit \"replicas\":[]", gotBody) |
| + } |
| +} |
diff --git a/cli.go b/cli.go| index f6e7dceb3..afa848a7e 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -298,6 +298,9 @@ func runInstances(args []string) { |
| case "set-environment": |
| runInstancesSetEnvironment(args[1:]) |
| return |
| + case "set-replicas": |
| + runInstancesSetReplicas(args[1:]) |
| + return |
| } |
| } |
| fs := flag.NewFlagSet("instances", flag.ExitOnError) |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 60d098f31..73075db77 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -542,9 +542,9 @@ working. Use `carlos env set` for a value that is meant to be empty. |
| An app's instances are the places it actually runs. `carlos instances enable` |
| is the app's opt-in to provisioning, and until it is set the API refuses to |
| create anything. After that, `create`, `list`, `delete`, `set-upstreams`, |
| -`set-channel` and `set-environment` work on records through the console, and |
| -the box's reconcile pass turns a record into a live route. A password in |
| -front of an instance's hostname is `carlos gate`. |
| +`set-channel`, `set-environment` and `set-replicas` work on records through |
| +the console, and the box's reconcile pass turns a record into a live route. |
| +A password in front of an instance's hostname is `carlos gate`. |
| An instance may sit at any free label under the account's platform domain, |
| but not at another app's platform apex. That is refused while the other app |
| @@ -580,6 +580,31 @@ bind it here, then restart the instance to pick the values up. |
| carlos instances set-environment --app jam --host new-queue.jam.bab.oncarlos.com --environment jam-canary |
| ``` |
| +The `ROLE` column is empty for an ordinary instance — the *home*, the |
| +authoritative copy of an account's data — and reads `replica` for a REPLICA: |
| +a second instance of the same account, holding a one-way mirror of its home, |
| +placed in a second region so the edge nearest its buyers answers locally |
| +instead of backhauling every request to the home's region. |
| + |
| +Bringing a replica up is four steps: create the home, turn on latency |
| +steering (so the account's public host can resolve to more than one edge), |
| +opt the home into the placement with `set-replicas`, then create the replica |
| +itself with `--role replica --home <the home's host>`. The console mints the |
| +replica's own host and stamps its home's channel on it — `--host` and |
| +`--channel` are refused alongside `--role replica`. |
| + |
| +```sh |
| +carlos instances create --app hello --host hello.example.com --region ie |
| +carlos steering --app hello --host hello.example.com latency |
| +carlos instances set-replicas --app hello --host hello.example.com --replicas uk |
| +carlos instances create --app hello --role replica --home hello.example.com --region uk |
| +``` |
| + |
| +The last command prints the host the console minted for the replica. Until |
| +its own placement is withdrawn with `set-replicas` (refused while the |
| +replica record still exists — delete it first), the home keeps its opt-in |
| +even if the replica itself is later deleted and re-created. |
| + |
| Typed on a box with no sub-verb, `carlos instances` does something different: |
| it lists that box's own registry routes, backing and owning unit included. |
| Off the box, [carlos routes](/docs/cli#carlos-routes) answers the same |
diff --git a/instances_cli.go b/instances_cli.go| index 5ffc16865..6d3b3020e 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -26,6 +26,7 @@ import ( |
| "errors" |
| "flag" |
| "fmt" |
| + "io" |
| "log" |
| "os" |
| "strings" |
| @@ -182,12 +183,15 @@ func instancesEnable(ctx context.Context, s release.Store, acct, app string, m p |
| const instancesRecordsUsage = `usage: carlos instances create --app <a> --host <h> [--region <r>] [--channel <c>] |
| [--kind instance|service|static] [--environment <e>] |
| [--addr <a:p,...>] [--health-path <p>] [--health-status <n>] |
| + carlos instances create --app <a> --role replica --home <h> [--region <r>] |
| + [--kind ...] [--environment <e>] [--addr <a:p,...>] [...] |
| carlos instances list --app <a> [--match <substr>] [--health <slug>] |
| carlos instances delete --app <a> --host <h> |
| carlos instances set-upstreams --app <a> --host <h> [--addrs <a:p,...>] |
| [--health-path <p>] [--health-status <n>] |
| carlos instances set-channel --app <a> --host <h> --channel <c> |
| carlos instances set-environment --app <a> --host <h> --environment <e> |
| + carlos instances set-replicas --app <a> --host <h> --replicas <r1,r2,...> |
| Signed-in only (carlos auth login): instance records are console-authorized |
| writes — the console checks the host against the app's manifest and the |
| @@ -218,6 +222,21 @@ and for the same reason set-channel exists rather than "carlos route |
| --environment": the reconciler reverts the box-side edit within seconds. |
| Restart the instance afterwards to pick the new values up. |
| +--role replica creates a REPLICA: a second instance of the same account's |
| +app, holding a one-way mirror of its data, placed near its buyers rather |
| +than backhauling every request home. --home names the instance it mirrors; |
| +the console mints the replica's own host (--host and --channel are refused |
| +alongside --role replica — a replica always runs its home's channel) and |
| +refuses the placement unless the home is latency-steered (carlos steering) |
| +and has already opted --region into its replicas with set-replicas below. |
| + |
| +set-replicas is the HOME's opt-in: the set of placement codes its account |
| +agrees a replica may live in. It must be set — and latency steering turned |
| +on — before "instances create --role replica --home <h> --region <r>" for |
| +that region will succeed. Withdrawing a placement (an explicit --replicas |
| +with that code left out, or --replicas "" to withdraw every one) is refused |
| +while a replica record still lives there; delete the replica first. |
| + |
| set-upstreams-only flags: |
| --addrs <a:p,...> comma-separated TCP upstream set, host:port each, |
| @@ -259,24 +278,59 @@ func requireAPISession(verb, consoleFlag string) *apiSession { |
| return sess |
| } |
| +// validateReplicaCreateArgs is runInstancesCreate's local, pre-request |
| +// refusal for --role/--home (#162, spec §12) — pulled out as a plain |
| +// function, rather than left inline with log.Fatal, so a test can drive it |
| +// directly: runInstancesCreate itself returns nothing and exits the process |
| +// on a bad invocation, the shape every other verb in this file follows |
| +// (flag.ExitOnError plus log.Fatal), which is not itself a good unit-test |
| +// target. |
| +// |
| +// Both directions are refused here, ahead of any round trip, mirroring |
| +// handleAPIInstanceCreate's own two refusals (internal/console/apiinstances.go): |
| +// --role replica with no --home has named nothing to mirror, and a bare |
| +// --home with no --role replica would otherwise be silently ignored, leaving |
| +// the caller believing they created a mirror when they did not. |
| +func validateReplicaCreateArgs(role, home string) error { |
| + if !provision.ValidRole(role) { |
| + return fmt.Errorf("instances create: role %q: want \"\" or \"replica\"", role) |
| + } |
| + switch { |
| + case role == "replica" && home == "": |
| + return fmt.Errorf("instances create --role replica needs --home <the instance it mirrors>") |
| + case role != "replica" && home != "": |
| + return fmt.Errorf("instances create: --home names the instance a REPLICA mirrors — pass --role replica too, or drop --home") |
| + } |
| + return nil |
| +} |
| + |
| func runInstancesCreate(args []string) { |
| fs := flag.NewFlagSet("instances create", flag.ExitOnError) |
| messageFlags := addMutationMessageFlags(fs) |
| app := appFlag(fs, "app the instance belongs to") |
| - host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain") |
| - region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list)") |
| - channel := channelFlag(fs, "release channel the instance follows (default: the manifest's, then this app's other instances, then its pipeline entry)") |
| + host := fs.String("host", "", "instance host — one DNS label + \".\" + the app's instance domain (refused with --role replica: the console mints a replica's own host)") |
| + region := fs.String("region", "", "region code for the record (subject to the manifest's allow-list; for --role replica, the placement it mirrors into — subject to the home's own replicas opt-in)") |
| + channel := channelFlag(fs, "release channel the instance follows (default: the manifest's, then this app's other instances, then its pipeline entry; refused with --role replica: a replica always runs its home's channel)") |
| kind := fs.String("kind", "", "route class: instance (default) | service | static — a static record serves a file tree off the channel pointer and has no process") |
| environment := fs.String("environment", "", "named config bundle this route binds to (default: the app's default bundle) — carlos env environments lists them") |
| addr := fs.String("addr", "", "comma-separated TCP upstream set, host:port each (e.g. 10.0.0.1:8090,10.0.0.2:8090); omitted means the platform's own default upstream") |
| healthPath := fs.String("health-path", "", "states the whole probe alongside --health-status: request path (default \"/\"); honoured for a single --addr too") |
| healthStatus := fs.Int("health-status", 0, "probe's expected response status (default 200); fields neither flag names take the platform's own defaults") |
| + role := fs.String("role", "", "instance role: \"\" (default, a serving home) or \"replica\" — a second instance of the same account, mirroring a home placed near its buyers; requires --home") |
| + home := fs.String("home", "", "for --role replica: the public host of the HOME instance this one mirrors; that home must be latency-steered and already opted this --region into its replicas (carlos instances set-replicas)") |
| consoleFlag := consoleOption(fs, "console to create through (default: the only logged-in one)") |
| acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| fs.Usage = flagUsage(fs, instancesRecordsUsage) |
| fs.Parse(args) |
| - if *host == "" { |
| + roleVal := strings.TrimSpace(*role) |
| + homeVal := strings.TrimSpace(*home) |
| + if err := validateReplicaCreateArgs(roleVal, homeVal); err != nil { |
| + log.Fatal(err) |
| + } |
| + // A replica's host is minted by the console (it has no --host to give); |
| + // every other shape still requires one. |
| + if roleVal != "replica" && *host == "" { |
| log.Fatal(instancesRecordsUsage) |
| } |
| set := map[string]bool{} |
| @@ -291,9 +345,16 @@ func runInstancesCreate(args []string) { |
| if merr != nil { |
| log.Fatalf("instances create: %v", merr) |
| } |
| - rec, err := sess.instanceCreate(context.Background(), acct, app2, |
| - strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel), |
| - strings.TrimSpace(*kind), strings.TrimSpace(*environment), addrs, probe, message) |
| + var rec apiInstanceRecord |
| + var err error |
| + if roleVal == "replica" { |
| + rec, err = sess.instanceCreateReplica(context.Background(), acct, app2, |
| + homeVal, strings.TrimSpace(*region), strings.TrimSpace(*kind), strings.TrimSpace(*environment), addrs, probe, message) |
| + } else { |
| + rec, err = sess.instanceCreate(context.Background(), acct, app2, |
| + strings.TrimSpace(*host), strings.TrimSpace(*region), strings.TrimSpace(*channel), |
| + strings.TrimSpace(*kind), strings.TrimSpace(*environment), addrs, probe, message) |
| + } |
| if err != nil { |
| log.Fatalf("instances create: %v", err) |
| } |
| @@ -352,6 +413,25 @@ func upstreamsCell(ups []apiUpstreamState) string { |
| return fmt.Sprintf("%d/%d", up, len(ups)) |
| } |
| +// printInstances renders the `carlos instances list` table — pulled out of |
| +// runInstancesList (#162) so a test can drive it against a literal slice of |
| +// records without a console round trip. ROLE prints the record's own Role |
| +// verbatim: empty for a serving home, "replica" for a mirror — never the |
| +// word "home", which would claim the field is set when it is not. |
| +func printInstances(w io.Writer, recs []apiInstanceRecord) { |
| + t := newTable(w) |
| + fmt.Fprintln(t, "HOST\tREGION\tCHANNEL\tROLE\tPOOL\tVERSION\tHEALTH\tUPSTREAMS\tDB\tCREATED\tBY") |
| + for _, r := range recs { |
| + rowHealth := r.Health |
| + if rowHealth == "" { |
| + rowHealth = "—" // an old console: the field predates it, not "unhealthy" |
| + } |
| + fmt.Fprintf(t, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", |
| + r.Host, r.Region, r.Channel, r.Role, r.Pool, r.StagedVersion, rowHealth, upstreamsCell(r.Upstreams), humanBytes(r.DBBytes), r.CreatedAt, r.CreatedBy) |
| + } |
| + t.Flush() |
| +} |
| + |
| func runInstancesList(args []string) { |
| fs := flag.NewFlagSet("instances list", flag.ExitOnError) |
| app := appFlag(fs, "app whose instance records to list") |
| @@ -369,16 +449,7 @@ func runInstancesList(args []string) { |
| if err != nil { |
| log.Fatalf("instances list: %v", err) |
| } |
| - w := newTable(stdout) |
| - fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tHEALTH\tUPSTREAMS\tDB\tCREATED\tBY") |
| - for _, r := range recs { |
| - rowHealth := r.Health |
| - if rowHealth == "" { |
| - rowHealth = "—" // an old console: the field predates it, not "unhealthy" |
| - } |
| - fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.StagedVersion, rowHealth, upstreamsCell(r.Upstreams), humanBytes(r.DBBytes), r.CreatedAt, r.CreatedBy) |
| - } |
| - w.Flush() |
| + printInstances(stdout, recs) |
| if truncated { |
| mutedf(stdout, "(truncated at 500 records)") |
| } |
| @@ -631,6 +702,59 @@ func runInstancesSetChannel(args []string) { |
| } |
| } |
| +// runInstancesSetReplicas is `carlos instances set-replicas` — the CLI front |
| +// over handleAPIInstanceUpdate's PATCH replicas field (#162, spec §12): the |
| +// HOME's opt-in to which placements its account agrees may hold a mirror of |
| +// its data. It follows set-upstreams' shape for --replicas (a comma-split |
| +// flag, given-vs-absent distinguished the way --addrs is, so an explicit |
| +// empty value withdraws every placement rather than being mistaken for "flag |
| +// not given"). |
| +// |
| +// This is the door `carlos instances create --role replica --home <h>` |
| +// depends on: the console refuses to create a replica in a placement its |
| +// home has not opted into here, and latency steering (carlos steering) must |
| +// already be on before a placement can be added. |
| +func runInstancesSetReplicas(args []string) { |
| + fs := flag.NewFlagSet("instances set-replicas", flag.ExitOnError) |
| + app := appFlag(fs, "app the instance belongs to") |
| + host := fs.String("host", "", "instance host — the HOME record whose replica opt-in to change") |
| + replicasFlag := fs.String("replicas", "", "comma-separated placement codes the account opts its replicas into, replacing the whole set; an explicit --replicas \"\" withdraws every placement (refused while a replica record still lives in one — delete it first)") |
| + consoleFlag := consoleOption(fs, "console to act through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| + fs.Usage = flagUsage(fs, instancesRecordsUsage) |
| + fs.Parse(args) |
| + if *host == "" || !flagWasSet(fs, "replicas") { |
| + log.Fatal(instancesRecordsUsage) |
| + } |
| + list := parseAddrList(*replicasFlag, true) |
| + if list == nil { |
| + // --replicas given but empty (or all-whitespace) — the wire's |
| + // explicit "withdraw every placement" spelling ("replicas":[]), not |
| + // "leave it alone" (a nil *[]string). parseAddrList's given=true |
| + // already tells nil from absent for this caller; this turns that |
| + // nil into the non-nil empty slice the PATCH door requires to see a |
| + // clear rather than a no-op — the same turn instanceSetUpstreams' |
| + // addrs handling makes. |
| + list = []string{} |
| + } |
| + sess := requireAPISession("set-replicas", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, instancesRecordsUsage) |
| + rec, err := sess.instanceSetReplicas(context.Background(), acct, app2, strings.TrimSpace(*host), &list) |
| + if err != nil { |
| + log.Fatalf("instances set-replicas: %v", err) |
| + } |
| + if len(list) == 0 { |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("cleared replica placements for"), rec.Host) |
| + } else { |
| + fmt.Fprintf(stdout, "%s %s: %s\n", colorFor(stdout).ok("replica placements set for"), rec.Host, strings.Join(list, ", ")) |
| + } |
| + mutedf(stdout, " the opt-in is recorded immediately; create the replica itself with:") |
| + mutedf(stdout, " carlos instances create --app %s --role replica --home %s --region <code>", app2, rec.Host) |
| +} |
| + |
| // environmentSuffix renders a record's config binding for a one-line |
| // confirmation, and renders NOTHING for the default bundle. The empty case |
| // is the common one and naming it ("environment default") on every create |
diff --git a/instances_cli_test.go b/instances_cli_test.go| index 395408395..db903e9dd 100644 |
| --- a/instances_cli_test.go |
| +++ b/instances_cli_test.go |
| @@ -8,9 +8,11 @@ |
| package main |
| import ( |
| + "bytes" |
| "context" |
| "net/http" |
| "net/http/httptest" |
| + "strings" |
| "testing" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -145,6 +147,55 @@ func TestParseProbeFlags(t *testing.T) { |
| } |
| } |
| +// TestInstancesListShowsRole pins printInstances' new ROLE column (#162, |
| +// spec §12): the printer is the extracted core of runInstancesList's table |
| +// output, testable now that it takes an io.Writer and the decoded records |
| +// rather than printing straight to the package's stdout seam. A home |
| +// record's Role is "" and must print as an empty cell, never the word |
| +// "home" — that would claim the field is set when it is not. |
| +func TestInstancesListShowsRole(t *testing.T) { |
| + var buf bytes.Buffer |
| + printInstances(&buf, []apiInstanceRecord{ |
| + {Host: "acme.go.tito.io", Region: "ie", Channel: "stable"}, |
| + {Host: "r7x2k9m4qa.go.tito.io", Region: "uk", Channel: "stable", |
| + Role: "replica", Home: "acme.go.tito.io"}, |
| + }) |
| + out := buf.String() |
| + if !strings.Contains(out, "ROLE") { |
| + t.Fatalf("no ROLE column:\n%s", out) |
| + } |
| + for _, line := range strings.Split(out, "\n") { |
| + if strings.HasPrefix(line, "acme.go.tito.io") && strings.Contains(line, "replica") { |
| + t.Errorf("a home was rendered as a replica:\n%s", line) |
| + } |
| + if strings.HasPrefix(line, "r7x2k9m4qa.go.tito.io") && !strings.Contains(line, "replica") { |
| + t.Errorf("a replica was not marked:\n%s", line) |
| + } |
| + } |
| +} |
| + |
| +// TestInstancesCreateReplicaNeedsAHome pins the local refusal |
| +// runInstancesCreate must give before any request crosses the wire: --role |
| +// replica with no --home. runInstancesCreate itself returns nothing and |
| +// calls log.Fatal on a bad invocation (like every other verb in this file), |
| +// so the refusal it defers to is pulled out as a plain function a test can |
| +// call directly, per the plan's revision 2 correction. |
| +func TestInstancesCreateReplicaNeedsAHome(t *testing.T) { |
| + err := validateReplicaCreateArgs("replica", "") |
| + if err == nil || !strings.Contains(err.Error(), "home") { |
| + t.Fatalf("err = %v, want a local refusal naming --home before any request", err) |
| + } |
| + if err := validateReplicaCreateArgs("replica", "acme.go.tito.io"); err != nil { |
| + t.Fatalf("a well-formed replica request was refused: %v", err) |
| + } |
| + if err := validateReplicaCreateArgs("", ""); err != nil { |
| + t.Fatalf("an ordinary (home) create was refused: %v", err) |
| + } |
| + if err := validateReplicaCreateArgs("", "acme.go.tito.io"); err == nil { |
| + t.Fatal("a --home with no --role replica was accepted — home means nothing without it") |
| + } |
| +} |
| + |
| func TestParseSeed(t *testing.T) { |
| cases := []struct { |
| in string |