assets: carlos assets purge|status and instances set-assets, with docs (edge asset cache plan 1, task 10)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
9 files changed,
+576
−2
apimode.go+107 −0approved_asset_cache_copy_test.go+2 −0assets_cli.go+162 −0assets_cli_test.go+202 −0cli.go+3 −0docs/site/cli.md+23 −0instances_assets.go+71 −0instances_cli.go+1 −0main.go+5 −2
diff --git a/apimode.go b/apimode.go| index cd94dee40..9ad9a4fe3 100644 |
| --- a/apimode.go |
| +++ b/apimode.go |
| @@ -5040,3 +5040,110 @@ func (s *apiSession) authorizeSign(ctx context.Context, a apiAcct, app, version, |
| _ = json.Unmarshal(body, &res) |
| return res.Result == "already", nil |
| } |
| + |
| +// instanceSetAssets turns one instance's asset copying on or off through the |
| +// console (edge asset cache design §3.2): "on" follows the release's |
| +// declaration, "off" catches nothing. The word is always sent, since the verb |
| +// has no leave-it-alone spelling. A console older than the field answers 400 |
| +// "nothing to update" to a body of only this key, reported as exactly that. |
| +func (s *apiSession) instanceSetAssets(ctx context.Context, a apiAcct, app, host string, on bool, messages ...string) (apiInstanceRecord, error) { |
| + word := "off" |
| + if on { |
| + word = "on" |
| + } |
| + in := struct { |
| + Assets string `json:"assets"` |
| + Message string `json:"message,omitempty"` |
| + }{word, firstMutationMessage(messages)} |
| + status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in) |
| + if err != nil { |
| + return apiInstanceRecord{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusBadRequest && strings.Contains(string(body), "nothing to update") { |
| + return apiInstanceRecord{}, fmt.Errorf("this console predates `carlos instances set-assets` (deploy a console built from current main)") |
| + } |
| + return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiInstanceRecord |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiInstanceRecord{}, fmt.Errorf("set-assets: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +// apiAssetsPurgeResult mirrors the purge door's reply (internal/console |
| +// handleAPIAssetsPurge): one line per instance, and Complete true only when |
| +// every write landed. |
| +type apiAssetsPurgeResult struct { |
| + Instances []struct { |
| + Host string `json:"host"` |
| + Gen string `json:"gen"` |
| + OK bool `json:"ok"` |
| + Error string `json:"error"` |
| + } `json:"instances"` |
| + Complete bool `json:"complete"` |
| +} |
| + |
| +// apiAssetsStatus mirrors the status door's reply (assetStatusView). |
| +type apiAssetsStatus struct { |
| + Instances []struct { |
| + Host string `json:"host"` |
| + Paths []string `json:"paths"` |
| + Assets string `json:"assets"` |
| + Gen string `json:"gen"` |
| + PublishedGen string `json:"published_gen"` |
| + PublishedAt string `json:"published_at"` |
| + } `json:"instances"` |
| + Truncated bool `json:"truncated"` |
| +} |
| + |
| +// assetsOlderConsole widens the byte-identical 404 of a console with no assets |
| +// door the way restart does: the mux answers an old console, an unknown |
| +// account and an app that is not there with the same bytes, so the message |
| +// names all three, shortest fix first. |
| +func assetsOlderConsole(status int, body []byte) error { |
| + if status == http.StatusNotFound { |
| + return fmt.Errorf("%w, or this console predates `carlos assets` "+ |
| + "(deploy a console built from current main)", apiErrorFromBody(status, body)) |
| + } |
| + return apiErrorFromBody(status, body) |
| +} |
| + |
| +// assetsPurge asks the console to draw a fresh asset generation for every |
| +// instance of the app. The reply says per instance whether its write landed; |
| +// a purge that stopped part way is a 200 with complete false, not an error. |
| +func (s *apiSession) assetsPurge(ctx context.Context, a apiAcct, app string, messages ...string) (apiAssetsPurgeResult, error) { |
| + in := struct { |
| + Message string `json:"message,omitempty"` |
| + }{firstMutationMessage(messages)} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/assets/purge"), in) |
| + if err != nil { |
| + return apiAssetsPurgeResult{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiAssetsPurgeResult{}, assetsOlderConsole(status, body) |
| + } |
| + var res apiAssetsPurgeResult |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiAssetsPurgeResult{}, fmt.Errorf("assets purge: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
| + |
| +// assetsStatus reads each instance's folders, opt-out, generation and the |
| +// generation its home published. |
| +func (s *apiSession) assetsStatus(ctx context.Context, a apiAcct, app string) (apiAssetsStatus, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/assets")) |
| + if err != nil { |
| + return apiAssetsStatus{}, err |
| + } |
| + if status != http.StatusOK { |
| + return apiAssetsStatus{}, assetsOlderConsole(status, body) |
| + } |
| + var res apiAssetsStatus |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return apiAssetsStatus{}, fmt.Errorf("assets status: unreadable response: %w", err) |
| + } |
| + return res, nil |
| +} |
diff --git a/approved_asset_cache_copy_test.go b/approved_asset_cache_copy_test.go| index 943f39ec5..14c5117c6 100644 |
| --- a/approved_asset_cache_copy_test.go |
| +++ b/approved_asset_cache_copy_test.go |
| @@ -49,6 +49,8 @@ var assetCacheCopyBuilt = []string{ |
| "EAC-21", "EAC-22", |
| "EAC-2", "EAC-9", "EAC-20", |
| "EAC-15", |
| + "EAC-10", "EAC-11", "EAC-12", "EAC-13", "EAC-14", |
| + "EAC-16", "EAC-17", "EAC-18", "EAC-19", |
| } |
| // assetCacheBuiltRows turns built entries into the rows to check: one per |
diff --git a/assets_cli.go b/assets_cli.go| new file mode 100644 |
| index 000000000..fc44cb0c3 |
| --- /dev/null |
| +++ b/assets_cli.go |
| @@ -0,0 +1,162 @@ |
| +// The `carlos assets` verb family (edge asset cache plan 1, task 10): purge |
| +// the copies servers keep of an app's asset files, and read where a purge has |
| +// reached. Signed-in only, like features: a purge is a console write with an |
| +// audit row. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "strings" |
| +) |
| + |
| +const assetsUsage = `usage: carlos assets <purge|status> [flags] |
| + |
| + purge --app <a> [flags] |
| + make every instance of the app forget the asset files it has |
| + saved, so the next request for each goes to the app |
| + status --app <a> [flags] |
| + each instance with the folders its release declares, whether |
| + asset copying is on, and the generation the purge has reached |
| + |
| + --account <a> account (sqid or name; default: your only account) |
| + --account-id <id> account sqid, matched as a sqid ONLY |
| + --console <origin> console to act through (default: the only logged-in one) |
| + |
| +Signed-in only (carlos auth login). |
| +` |
| + |
| +const assetsPurgeUsage = `usage: carlos assets purge --app <a> [--account <a>] [--account-id <id>] [--console <origin>] |
| +` |
| + |
| +const assetsStatusUsageLine = `usage: carlos assets status --app <a> [--account <a>] [--account-id <id>] [--console <origin>] |
| +` |
| + |
| +// The approved copy of the status table (EAC-14): the column heads, a blank |
| +// line, then the sentence that bounds how long a purge takes to show. It is |
| +// one expression so the record's text is checked whole; the printer cuts it at |
| +// the blank line and puts the rows between. |
| +const assetsStatusText = "HOST\tFOLDERS\tASSETS\tGENERATION\tPUBLISHED GENERATION\tPUBLISHED AT\n\n" + |
| + "a purge is done once the published generation matches the generation. " + |
| + "servers that can reach the platform pick up a new generation within a minute. " + |
| + "a server that cannot stops serving the app's saved asset files within an hour." |
| + |
| +// runAssets dispatches the sub-verbs. purge and status are positional words, |
| +// not flags, and must never reach fs.Parse. |
| +func runAssets(args []string) { |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "purge": |
| + runAssetsPurge(args[1:]) |
| + return |
| + case "status": |
| + runAssetsStatus(args[1:]) |
| + return |
| + } |
| + } |
| + log.Fatal(assetsUsage) |
| +} |
| + |
| +// requireAssetsAPISession refuses direct-bucket mode with assets' own |
| +// message, requireFeaturesAPISession's pattern and reason. |
| +func requireAssetsAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos assets works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A purge is a console write with an\n" + |
| + "audit row. Unset the store env and run: carlos auth login -console <url>.") |
| + } |
| + return sess |
| +} |
| + |
| +func runAssetsPurge(args []string) { |
| + fs := flag.NewFlagSet("assets purge", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| + app := appFlag(fs, "app whose saved asset files to purge") |
| + consoleFlag := consoleOption(fs, "console to purge 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.Parse(args) |
| + sess := requireAssetsAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, assetsPurgeUsage) |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("assets purge: %v", merr) |
| + } |
| + res, err := sess.assetsPurge(context.Background(), acct, app2, message) |
| + if err != nil { |
| + log.Fatalf("assets purge: %v", err) |
| + } |
| + if !printAssetsPurge(stdout, app2, res) { |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +// printAssetsPurge says what the purge did and reports whether it finished. |
| +// A purge that changed some instances and not others names the ones it left |
| +// and what to run (EAC-13); a whole one says so (EAC-12). |
| +func printAssetsPurge(w io.Writer, app string, res apiAssetsPurgeResult) (complete bool) { |
| + var left []string |
| + done := 0 |
| + for _, in := range res.Instances { |
| + if in.OK { |
| + done++ |
| + continue |
| + } |
| + left = append(left, in.Host) |
| + } |
| + if res.Complete && len(left) == 0 { |
| + fmt.Fprintln(w, fmt.Sprintf("purged the saved asset files of %s on %d instances. servers drop the old copies within a minute.", app, done)) |
| + return true |
| + } |
| + fmt.Fprintln(w, fmt.Sprintf("the purge of %s changed %d of %d instances and left these unchanged: %s. run carlos assets purge --app %s again to finish it.", |
| + app, done, len(res.Instances), strings.Join(left, ", "), app)) |
| + return false |
| +} |
| + |
| +func runAssetsStatus(args []string) { |
| + fs := flag.NewFlagSet("assets status", flag.ExitOnError) |
| + app := appFlag(fs, "app whose asset copying to show") |
| + consoleFlag := consoleOption(fs, "console to read 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.Parse(args) |
| + sess := requireAssetsAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, assetsStatusUsageLine) |
| + st, err := sess.assetsStatus(context.Background(), acct, app2) |
| + if err != nil { |
| + log.Fatalf("assets status: %v", err) |
| + } |
| + printAssetsStatus(stdout, st) |
| +} |
| + |
| +// printAssetsStatus prints the heads, a row per instance, a blank line and the |
| +// bound sentence (EAC-14). An empty cell reads "-". |
| +func printAssetsStatus(w io.Writer, st apiAssetsStatus) { |
| + heads, bound, _ := strings.Cut(assetsStatusText, "\n\n") |
| + dash := func(s string) string { |
| + if s == "" { |
| + return "-" |
| + } |
| + return s |
| + } |
| + t := newTable(w) |
| + fmt.Fprintln(t, heads) |
| + for _, in := range st.Instances { |
| + fmt.Fprintf(t, "%s\t%s\t%s\t%s\t%s\t%s\n", in.Host, dash(strings.Join(in.Paths, ",")), dash(in.Assets), |
| + dash(in.Gen), dash(in.PublishedGen), dash(in.PublishedAt)) |
| + } |
| + t.Flush() |
| + fmt.Fprintf(w, "\n%s\n", bound) |
| +} |
diff --git a/assets_cli_test.go b/assets_cli_test.go| new file mode 100644 |
| index 000000000..cf5b44663 |
| --- /dev/null |
| +++ b/assets_cli_test.go |
| @@ -0,0 +1,202 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// assetsDoor is a stand-in console for the CLI verbs: it records the last |
| +// request and answers with the given status and body. Not acceptance: the |
| +// real doors are tested in internal/console. |
| +func assetsDoor(t *testing.T, status int, reply string) (*apiSession, *struct{ Method, Path, Body string }) { |
| + t.Helper() |
| + got := &struct{ Method, Path, Body string }{} |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + got.Method, got.Path = r.Method, r.URL.Path |
| + b, _ := io.ReadAll(r.Body) |
| + got.Body = string(b) |
| + w.WriteHeader(status) |
| + io.WriteString(w, reply) |
| + })) |
| + t.Cleanup(ts.Close) |
| + return newTestSession(ts.URL), got |
| +} |
| + |
| +// TestInstanceSetAssetsPatches pins instanceSetAssets's wire contract: a |
| +// PATCH to the instance door carrying "assets" as the word "on" or "off", |
| +// always sent, since the verb has no leave-it-alone spelling. |
| +func TestInstanceSetAssetsPatches(t *testing.T) { |
| + sess, got := assetsDoor(t, http.StatusOK, `{"host":"shop.example.com"}`) |
| + acct := apiAcct{Seg: "abc"} |
| + rec, err := sess.instanceSetAssets(context.Background(), acct, "shop", "shop.example.com", false) |
| + if err != nil { |
| + t.Fatalf("instanceSetAssets: %v", err) |
| + } |
| + if rec.Host != "shop.example.com" { |
| + t.Fatalf("rec = %+v", rec) |
| + } |
| + if got.Method != http.MethodPatch || got.Path != "/api/cli/apps/abc/shop/instances/shop.example.com" { |
| + t.Fatalf("request = %s %s", got.Method, got.Path) |
| + } |
| + if got.Body != `{"assets":"off"}` { |
| + t.Fatalf("off body = %q", got.Body) |
| + } |
| + if _, err := sess.instanceSetAssets(context.Background(), acct, "shop", "shop.example.com", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got.Body != `{"assets":"on"}` { |
| + t.Fatalf("on body = %q", got.Body) |
| + } |
| +} |
| + |
| +// TestInstanceSetAssetsOlderConsole: a console that predates the field |
| +// answers 400 "nothing to update" for a body of only "assets"; the verb says |
| +// the console is older instead of passing that on. |
| +func TestInstanceSetAssetsOlderConsole(t *testing.T) { |
| + sess, _ := assetsDoor(t, http.StatusBadRequest, `{"error":"nothing to update — send addrs, probe, steering, environment, gate, or a combination"}`) |
| + _, err := sess.instanceSetAssets(context.Background(), apiAcct{Seg: "abc"}, "shop", "shop.example.com", false) |
| + if err == nil || !strings.Contains(err.Error(), "predates") { |
| + t.Fatalf("err = %v, want the console-predates form", err) |
| + } |
| +} |
| + |
| +// TestInstanceSetAssetsSurfacesRefusalVerbatim: any other refusal reaches the |
| +// caller as the door worded it. |
| +func TestInstanceSetAssetsSurfacesRefusalVerbatim(t *testing.T) { |
| + sess, _ := assetsDoor(t, http.StatusBadRequest, `{"error":"assets must be on or off."}`) |
| + _, err := sess.instanceSetAssets(context.Background(), apiAcct{Seg: "abc"}, "shop", "shop.example.com", true) |
| + if err == nil || err.Error() != "assets must be on or off." { |
| + t.Fatalf("err = %v", err) |
| + } |
| +} |
| + |
| +// TestSetAssetsResultPrintsBothSentences pins EAC-10 and EAC-11 as a person |
| +// reads them. |
| +func TestSetAssetsResultPrintsBothSentences(t *testing.T) { |
| + var b bytes.Buffer |
| + printSetAssets(&b, "shop.example.com", true) |
| + printSetAssets(&b, "shop.example.com", false) |
| + want := "shop.example.com will keep copies of the asset folders its release declares. servers pick this up within a minute.\n" + |
| + "shop.example.com will keep no copies of its asset files, so every request for them reaches the app. servers pick this up within a minute.\n" |
| + if b.String() != want { |
| + t.Fatalf("got\n%q\nwant\n%q", b.String(), want) |
| + } |
| +} |
| + |
| +const purgeDoneReply = `{"instances":[{"host":"a.shop.example.com","gen":"0123456789abcdef","ok":true},{"host":"b.shop.example.com","gen":"fedcba9876543210","ok":true},{"host":"c.shop.example.com","gen":"1111111111111111","ok":true}],"complete":true}` |
| +const purgePartialReply = `{"instances":[{"host":"a.shop.example.com","gen":"0123456789abcdef","ok":true},{"host":"b.shop.example.com","ok":false,"error":"could not update the instance record"},{"host":"c.shop.example.com","gen":"1111111111111111","ok":true}],"complete":false}` |
| + |
| +// TestAssetsPurgePrintsComplete pins the wire (POST to the purge door) and |
| +// EAC-12. |
| +func TestAssetsPurgePrintsComplete(t *testing.T) { |
| + sess, got := assetsDoor(t, http.StatusOK, purgeDoneReply) |
| + res, err := sess.assetsPurge(context.Background(), apiAcct{Seg: "abc"}, "shop") |
| + if err != nil { |
| + t.Fatalf("assetsPurge: %v", err) |
| + } |
| + if got.Method != http.MethodPost || got.Path != "/api/cli/apps/abc/shop/assets/purge" { |
| + t.Fatalf("request = %s %s", got.Method, got.Path) |
| + } |
| + var b bytes.Buffer |
| + if !printAssetsPurge(&b, "shop", res) { |
| + t.Fatal("printAssetsPurge reported incomplete for a complete purge") |
| + } |
| + want := "purged the saved asset files of shop on 3 instances. servers drop the old copies within a minute.\n" |
| + if b.String() != want { |
| + t.Fatalf("got %q, want %q", b.String(), want) |
| + } |
| +} |
| + |
| +// TestAssetsPurgePrintsPartial: a purge that stopped part way names the |
| +// instance that did not change and says to run it again (EAC-13). |
| +func TestAssetsPurgePrintsPartial(t *testing.T) { |
| + sess, _ := assetsDoor(t, http.StatusOK, purgePartialReply) |
| + res, err := sess.assetsPurge(context.Background(), apiAcct{Seg: "abc"}, "shop") |
| + if err != nil { |
| + t.Fatalf("assetsPurge: %v", err) |
| + } |
| + var b bytes.Buffer |
| + if printAssetsPurge(&b, "shop", res) { |
| + t.Fatal("printAssetsPurge reported complete for a partial purge") |
| + } |
| + want := "the purge of shop changed 2 of 3 instances and left these unchanged: b.shop.example.com. run carlos assets purge --app shop again to finish it.\n" |
| + if b.String() != want { |
| + t.Fatalf("got %q, want %q", b.String(), want) |
| + } |
| +} |
| + |
| +// TestAssetsPurgeOlderConsole: a 404 from the purge door is the mux of a |
| +// console that has no such route, or an unknown app; the message says both, |
| +// the form the restart verb uses. |
| +func TestAssetsPurgeOlderConsole(t *testing.T) { |
| + sess, _ := assetsDoor(t, http.StatusNotFound, "404 page not found\n") |
| + _, err := sess.assetsPurge(context.Background(), apiAcct{Seg: "abc"}, "shop") |
| + if err == nil || !strings.Contains(err.Error(), "this console predates `carlos assets`") { |
| + t.Fatalf("err = %v, want the console-predates form", err) |
| + } |
| + if !strings.Contains(err.Error(), "check --account and --app") { |
| + t.Fatalf("err = %v, want the membership reading kept", err) |
| + } |
| + if _, err := sess.assetsStatus(context.Background(), apiAcct{Seg: "abc"}, "shop"); err == nil || !strings.Contains(err.Error(), "predates `carlos assets`") { |
| + t.Fatalf("status err = %v", err) |
| + } |
| +} |
| + |
| +const statusReply = `{"instances":[` + |
| + `{"host":"a.shop.example.com","paths":["/assets/","/_app/immutable/"],"assets":"on","gen":"0123456789abcdef","published_gen":"0123456789abcdef","published_at":"2026-10-24T10:00:00Z"},` + |
| + `{"host":"b.shop.example.com","paths":[],"assets":"off","gen":"fedcba9876543210"}` + |
| + `],"truncated":false}` |
| + |
| +// TestAssetsStatusPrintsTheBound pins the status wire (GET) and EAC-14: the |
| +// column heads, a row per instance, and the bound sentence after them. |
| +func TestAssetsStatusPrintsTheBound(t *testing.T) { |
| + sess, got := assetsDoor(t, http.StatusOK, statusReply) |
| + st, err := sess.assetsStatus(context.Background(), apiAcct{Seg: "abc"}, "shop") |
| + if err != nil { |
| + t.Fatalf("assetsStatus: %v", err) |
| + } |
| + if got.Method != http.MethodGet || got.Path != "/api/cli/apps/abc/shop/assets" { |
| + t.Fatalf("request = %s %s", got.Method, got.Path) |
| + } |
| + var b bytes.Buffer |
| + printAssetsStatus(&b, st) |
| + out := b.String() |
| + for _, want := range []string{"HOST", "FOLDERS", "ASSETS", "GENERATION", "PUBLISHED GENERATION", "PUBLISHED AT", |
| + "a.shop.example.com", "/assets/,/_app/immutable/", "0123456789abcdef", "2026-10-24T10:00:00Z", |
| + "b.shop.example.com", "off", "fedcba9876543210"} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("status output lacks %q:\n%s", want, out) |
| + } |
| + } |
| + const bound = "\n\na purge is done once the published generation matches the generation. servers that can reach the platform pick up a new generation within a minute. a server that cannot stops serving the app's saved asset files within an hour.\n" |
| + if !strings.HasSuffix(out, bound) { |
| + t.Errorf("status output does not end with the bound sentence:\n%s", out) |
| + } |
| + if !strings.HasPrefix(out, "HOST") { |
| + t.Errorf("status output does not start with the heads:\n%s", out) |
| + } |
| + // The first body row sits between the heads and the bound. |
| + if strings.Index(out, "a.shop.example.com") < strings.Index(out, "PUBLISHED AT") { |
| + t.Errorf("rows come before the heads:\n%s", out) |
| + } |
| +} |
| + |
| +// TestAssetsStatusDecodesTheWire keeps the JSON field names the status door |
| +// writes (internal/console assetStatusView) in step with the client's. |
| +func TestAssetsStatusDecodesTheWire(t *testing.T) { |
| + var st apiAssetsStatus |
| + if err := json.Unmarshal([]byte(statusReply), &st); err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(st.Instances) != 2 || st.Instances[0].PublishedGen != "0123456789abcdef" || |
| + st.Instances[0].PublishedAt != "2026-10-24T10:00:00Z" || st.Instances[1].Assets != "off" || |
| + len(st.Instances[0].Paths) != 2 { |
| + t.Fatalf("decoded = %+v", st) |
| + } |
| +} |
diff --git a/cli.go b/cli.go| index c575d40bb..19fe5b679 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -400,6 +400,9 @@ func runInstances(args []string) { |
| case "set-environment": |
| runInstancesSetEnvironment(args[1:]) |
| return |
| + case "set-assets": |
| + runInstancesSetAssets(args[1:]) |
| + return |
| case "retry": |
| runInstancesRetry(args[1:]) |
| return |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 0a8e384f2..81a16a9bb 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -224,6 +224,8 @@ off is the point of having one: ship mints the next iteration under it. Use |
| is one line for humans and shows up in `carlos releases` and in the console, |
| so write it for the person doing a rollback at 2am. |
| +`--assets` names a folder your build writes its files to, for example `/assets/`. Repeat it for up to 8 folders. Each must start and end with `/`, be at most 128 bytes, and contain none of `%`, `?`, `#`, `*` or `..`. `/` alone is refused, because it would keep copies of every page. No folder may sit inside another. A static site cannot use it, because a static site is already served from every region. The folders belong to the release, so a rollback to an older build uses the folders that build declared. |
| + |
| ### carlos promote |
| Points a channel at a version you have already shipped. |
| @@ -304,6 +306,8 @@ new build, for a migration the previous build cannot share the database |
| with. It applies to that release only; `carlos apps set --swap hold` makes it |
| the rule for the app. |
| +`carlos deploy` takes the same repeatable `--assets` flag and passes it to the ship. Save the list as `"assets": ["/assets/"]` in `.carlos/config` and a bare `carlos deploy` declares it on every release. A flag on the command line replaces the saved list for that run, and `--no-assets` ships with none. A release that declares no folders gets no copies, so deploying a build without them turns copying off for that build. `--assets` and `--no-assets` cannot be given together. |
| + |
| To try a build that failed again, once you have fixed what it depended on, |
| promote it again with `--retry`. Every box where it failed tries it again: |
| @@ -967,6 +971,14 @@ Asks the instance's box to install the newest build it considers ready, even wit |
| carlos instances update a.shop.example.com |
| ``` |
| +### carlos instances set-assets |
| + |
| +Turns asset copying on or off for one instance. `off` means requests for its asset files always reach the app, which helps while you are debugging. `on` goes back to following the folders its release declares. An instance follows its release until you set it. |
| + |
| +```sh |
| +carlos instances set-assets --app shop a.shop.example.com off |
| +``` |
| + |
| ### carlos sidecars |
| A sidecar is a member that nothing can dial: a process the platform runs for |
| @@ -1170,6 +1182,17 @@ restart, no environment edit, no root-owned file on an edge box. Setting is |
| an admin-role write and every change is audited under the app's Activity; |
| the app's own instance credential can read the flags but never set them. |
| +### carlos assets |
| + |
| +Throws away or inspects the copies that servers keep of an app's asset files. `carlos assets purge --app shop` makes every instance of the app forget what it has saved, so the next request for each file goes to the app. If it stops part way it names the instances that did not change, and running it again finishes the job. `carlos assets status --app shop` lists each instance with the folders its release declares, whether asset copying is on, and the generation the purge has reached. Copies are only kept for folders the release declares with `--assets`. |
| + |
| +A purge reaches every server that can read the platform's storage within a minute. A server that cannot stops serving the app's saved asset files within an hour. |
| + |
| +```sh |
| +carlos assets purge --app shop |
| +carlos assets status --app shop |
| +``` |
| + |
| ### carlos gate |
| Puts one shared password in front of an app's hostnames — for a static site |
diff --git a/instances_assets.go b/instances_assets.go| new file mode 100644 |
| index 000000000..0607acce5 |
| --- /dev/null |
| +++ b/instances_assets.go |
| @@ -0,0 +1,71 @@ |
| +// `carlos instances set-assets <host> on|off` (edge asset cache plan 1, task |
| +// 10): the per-instance switch for asset copying. It rides the instance PATCH |
| +// door like set-steering and gate, so it is signed-in only. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "strings" |
| +) |
| + |
| +const instancesSetAssetsUsage = `usage: carlos instances set-assets --app <a> [--account <a>] [--account-id <id>] [--console <origin>] <host> on|off |
| +` |
| + |
| +func runInstancesSetAssets(args []string) { |
| + fs := flag.NewFlagSet("instances set-assets", flag.ExitOnError) |
| + messageFlags := addMutationMessageFlags(fs) |
| + app := appFlag(fs, "app the instance belongs to") |
| + 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, instancesSetAssetsUsage) |
| + // The host and the word are positional and may come before or after the |
| + // flags: parse, take the leading words, parse what is left. |
| + var words []string |
| + rest := args |
| + for { |
| + fs.Parse(rest) |
| + rest = fs.Args() |
| + if len(rest) == 0 { |
| + break |
| + } |
| + words = append(words, rest[0]) |
| + rest = rest[1:] |
| + } |
| + if len(words) != 2 || (words[1] != "on" && words[1] != "off") { |
| + log.Fatal(instancesSetAssetsUsage) |
| + } |
| + // Records are keyed lowercase, so a host typed with capitals would miss |
| + // and read as a typo (#264). |
| + host := strings.ToLower(strings.TrimSpace(words[0])) |
| + on := words[1] == "on" |
| + sess := requireAPISession("set-assets", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, instancesSetAssetsUsage) |
| + message, merr := resolveMutationMessage(fs, messageFlags) |
| + if merr != nil { |
| + log.Fatalf("instances set-assets: %v", merr) |
| + } |
| + if _, err := sess.instanceSetAssets(context.Background(), acct, app2, host, on, message); err != nil { |
| + err = recordNotFound(context.Background(), sess, acct, app2, host, err, func(h string) string { |
| + return fmt.Sprintf("%s is a box route with no instance record, so the console has nothing to switch", h) |
| + }) |
| + log.Fatalf("instances set-assets: %v", err) |
| + } |
| + printSetAssets(stdout, host, on) |
| +} |
| + |
| +// printSetAssets is the result line of set-assets (EAC-10 for on, EAC-11 for |
| +// off). |
| +func printSetAssets(w io.Writer, host string, on bool) { |
| + if on { |
| + fmt.Fprintln(w, fmt.Sprintf("%s will keep copies of the asset folders its release declares. servers pick this up within a minute.", host)) |
| + return |
| + } |
| + fmt.Fprintln(w, fmt.Sprintf("%s will keep no copies of its asset files, so every request for them reaches the app. servers pick this up within a minute.", host)) |
| +} |
diff --git a/instances_cli.go b/instances_cli.go| index 1824a8012..a0d538d83 100644 |
| --- a/instances_cli.go |
| +++ b/instances_cli.go |
| @@ -219,6 +219,7 @@ var instancesRecordsUsage = `usage: carlos instances create --app <a> --host <h> |
| [--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-assets --app <a> <host> on|off |
| carlos instances retry --app <a> <host> |
| ` + updatewords.Text("settings.cli.instances-usage-lines") + ` |
diff --git a/main.go b/main.go| index 3f13a396f..b88bd9881 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -75,6 +75,8 @@ func main() { |
| runDomains(args) |
| case "features": |
| runFeatures(args) |
| + case "assets": |
| + runAssets(args) |
| case "gate": |
| runGate(args) |
| case "ship": |
| @@ -260,8 +262,8 @@ Your apps — ship and run software on a CARLOS deployment |
| env plain per-app config vars: set | unset | list |
| secrets sealed per-app secrets: set | unset | list | genkey |
| instances an app's instances: enable | create | list | delete | |
| - set-upstreams | set-channel — set-channel is the only |
| - door onto which channel a route follows |
| + set-upstreams | set-channel | set-assets — set-channel is |
| + the only door onto which channel a route follows |
| sidecars a process nothing dials, one per enrolled member: enable | |
| create | ls | wake | rm | logs |
| steering opt an instance into DNS latency steering, or back off — |
| @@ -272,6 +274,7 @@ Your apps — ship and run software on a CARLOS deployment |
| attach | detach | list |
| features an app's own feature flags, stored and served by the |
| platform, never interpreted: set | list |
| + assets the copies servers keep of an app's asset files: purge | status |
| gate a shared password in front of an app's hostnames |
| logs an app's merged log timeline: its own output plus platform |
| events (wakes, restarts, failures); -f follows live |