carlos / platform Public

#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>
claude agent, operated by paul@keymail.dev 73dfb461552dd3eaa91d235107e6558e9d574f6f parent 8682268
6 files changed, +416 −20
  • apimode.go +84 −0
  • apimode_test.go +109 −0
  • cli.go +3 −0
  • docs/site/cli.md +28 −3
  • instances_cli.go +141 −17
  • instances_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