carlos / platform Public

cli: env/secrets through the console API, and env sync (issues #43, #44)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013WVANvB83eHrRANRpEL8mn
Paul Campbell 1ce7c64a3c1e92eb00cfed40dcc05bd73f93164a parent 3bad507
3 files changed, +808 −41
  • apimode.go +159 −0
  • apimode_test.go +325 −0
  • appconfig_cli.go +324 −41
diff --git a/apimode.go b/apimode.go
index 0f22b2679..7c986bfd0 100644
--- a/apimode.go
+++ b/apimode.go
@@ -30,6 +30,7 @@ import (
"sort"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/ledger"
)
@@ -478,6 +479,164 @@ func (s *apiSession) ledgerBlob(ctx context.Context, account, app, name string,
return out.SHA256, nil
}
+// putJSON runs one JSON PUT and hands back the status and raw body without
+// interpreting a non-200 as an error itself — mirrors postJSON exactly
+// (apimode.go), only the HTTP method differs: the config door's write route
+// is PUT (`PUT /api/cli/apps/{account}/{app}/config`, handleAPIConfigPut) —
+// "here is the state this bundle should carry", not an append.
+func (s *apiSession) putJSON(ctx context.Context, path string, in any) (int, []byte, error) {
+ raw, err := json.Marshal(in)
+ if err != nil {
+ return 0, nil, err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPut, s.c.base+path, bytes.NewReader(raw))
+ if err != nil {
+ return 0, nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ if s.c.token != "" {
+ req.Header.Set("Authorization", "Bearer "+s.c.token)
+ }
+ res, err := s.c.httpc.Do(req)
+ if err != nil {
+ return 0, nil, err
+ }
+ defer res.Body.Close()
+ body, err := io.ReadAll(io.LimitReader(res.Body, 1<<20))
+ if err != nil {
+ return res.StatusCode, nil, err
+ }
+ return res.StatusCode, body, nil
+}
+
+// apiConfigVar is one row of a config bundle read back through the console —
+// GET /api/cli/apps/{account}/{app}/config's "vars" entries. Value is only
+// ever populated for Kind=="env": a secret row's value is structurally
+// absent on the wire (the console couldn't read it if it wanted to), so
+// Value simply decodes to "" for those rows, same as an env var's own empty
+// string would.
+type apiConfigVar struct {
+ Key, Kind, Value string
+}
+
+// apiConfig is one bundle's config read back through the console, mirroring
+// the GET door's response shape exactly. ExecDelivery and SecretsPubkey
+// decode to "" when the wire response omits them — exec_delivery for a
+// named-environment read (app-level only) or an unset default bundle;
+// secrets_pubkey when the console has no CARLOS_SECRETS_PUBKEY of its own
+// (see secretsPubKey's API-mode resolution order in appconfig_cli.go).
+// Environments is always present (possibly empty).
+type apiConfig struct {
+ Bundle string
+ ExecDelivery string
+ SecretsPubkey string
+ Environments []string
+ Vars []apiConfigVar
+}
+
+// configGet reads one app's config bundle through the console — the default
+// bundle when environment=="", the named environment's own bundle
+// otherwise (matching loadBundle's own environment=="" convention in
+// appconfig_cli.go). The query parameter is sent ONLY when environment is
+// non-empty: the server's own zero value for "no ?environment=" is an
+// absent parameter, not one carrying "".
+func (s *apiSession) configGet(ctx context.Context, account, app, environment string) (apiConfig, error) {
+ path := apiPath(account, app, "/config")
+ if environment != "" {
+ path += "?environment=" + url.QueryEscape(environment)
+ }
+ status, body, err := s.getJSON(ctx, path)
+ if err != nil {
+ return apiConfig{}, err
+ }
+ if status != http.StatusOK {
+ return apiConfig{}, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Bundle string `json:"bundle"`
+ ExecDelivery string `json:"exec_delivery"`
+ SecretsPubkey string `json:"secrets_pubkey"`
+ Environments []string `json:"environments"`
+ Vars []struct {
+ Key string `json:"key"`
+ Kind string `json:"kind"`
+ Value string `json:"value"`
+ } `json:"vars"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiConfig{}, fmt.Errorf("config: unreadable response: %w", err)
+ }
+ out := apiConfig{
+ Bundle: res.Bundle,
+ ExecDelivery: res.ExecDelivery,
+ SecretsPubkey: res.SecretsPubkey,
+ Environments: res.Environments,
+ Vars: make([]apiConfigVar, len(res.Vars)),
+ }
+ for i, v := range res.Vars {
+ out.Vars[i] = apiConfigVar{Key: v.Key, Kind: v.Kind, Value: v.Value}
+ }
+ return out, nil
+}
+
+// apiConfigApply is one config write through the console's config door,
+// mirroring PUT /api/cli/apps/{account}/{app}/config's body exactly. Set's
+// values marshal through appconfig.Var's OWN json tags
+// (`{"kind","value","envelope"}`, value/envelope omitempty) — the wire
+// shape the spec pins for both env and secret vars, so no separate wire
+// struct is needed here. ExecDelivery is a pointer because "absent" (no
+// change requested) and "" are different requests on the wire, but the CLI
+// itself never sends "": SetExecDelivery("off") is always spelled "off",
+// the wire vocabulary handleAPIConfigPut maps onto SetExecDelivery("") —
+// see runEnvExecDelivery.
+type apiConfigApply struct {
+ Environment string `json:"environment"`
+ Set map[string]appconfig.Var `json:"set,omitempty"`
+ Unset []string `json:"unset,omitempty"`
+ ExecDelivery *string `json:"exec_delivery,omitempty"`
+}
+
+// configApply sends one PUT to the console's config door — a set, an unset,
+// and an exec-delivery change all in one CAS write server-side
+// (appconfig.Apply / ApplyEnvironment carry both halves of a mixed
+// set+unset in one call; see internal/appconfig/generation.go). removed is
+// the server's count of unsets ACTUALLY present, never the argument count —
+// the same rule unsetVars already follows for direct mode (a count of
+// arguments reads as success for a typo'd key).
+func (s *apiSession) configApply(ctx context.Context, account, app string, req apiConfigApply) (int, error) {
+ status, body, err := s.putJSON(ctx, apiPath(account, app, "/config"), req)
+ if err != nil {
+ return 0, err
+ }
+ if status != http.StatusOK {
+ return 0, apiErrorFromBody(status, body)
+ }
+ var res struct {
+ Result string `json:"result"`
+ Removed int `json:"removed"`
+ }
+ if err := json.Unmarshal(body, &res); err != nil {
+ return 0, fmt.Errorf("config: unreadable response: %w", err)
+ }
+ return res.Removed, nil
+}
+
+// configSync asks the console to touch app's generation object — the remote
+// half of `carlos env sync` (appconfig.TouchGeneration is the direct-mode
+// half; see runEnvSync in appconfig_cli.go). The response carries nothing
+// this client needs beyond success/failure: {"result": "requested"} is the
+// only body handleAPIConfigSync ever sends.
+func (s *apiSession) configSync(ctx context.Context, account, app string) error {
+ status, body, err := s.postJSON(ctx, apiPath(account, app, "/config/sync"), struct{}{})
+ if err != nil {
+ return err
+ }
+ if status != http.StatusOK {
+ return apiErrorFromBody(status, body)
+ }
+ return nil
+}
+
// shipTimeout bounds ship's own context — generous on purpose. The console
// gives itself up to 10 minutes to read the request body and 2 minutes to
// store it (internal/console/apicli.go), well past the 30s absolute Timeout
diff --git a/apimode_test.go b/apimode_test.go
index 8a05cecfe..cd34f5909 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -19,6 +19,7 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/edge"
"github.com/carlosframework/platform/internal/ledger"
"github.com/carlosframework/platform/internal/release"
@@ -1841,3 +1842,327 @@ func TestLedgerVerifyVerb(t *testing.T) {
t.Fatalf("stdout = %q, want the verified line", buf.String())
}
}
+
+// ---- Task 3: config client methods (configGet/configApply/configSync) ----
+
+// TestAPISessionConfigGetFullShape pins configGet's decode of the full wire
+// shape GET /api/cli/apps/{account}/{app}/config sends (spec §2): bundle,
+// exec_delivery, secrets_pubkey, environments, and vars of both kinds — a
+// secret row carries no value on the wire, and apiConfigVar.Value decodes to
+// "" for it exactly as it would for an env var that happened to be empty.
+func TestAPISessionConfigGetFullShape(t *testing.T) {
+ var gotPath string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ json.NewEncoder(w).Encode(map[string]any{
+ "bundle": "default",
+ "exec_delivery": "file",
+ "secrets_pubkey": "abcdef==",
+ "environments": []string{"staging"},
+ "vars": []map[string]any{
+ {"key": "PORT", "kind": "env", "value": "8080"},
+ {"key": "BANK_KEY", "kind": "secret"},
+ },
+ })
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ cfg, err := s.configGet(context.Background(), "acct", "app", "")
+ if err != nil {
+ t.Fatalf("configGet: %v", err)
+ }
+ if gotPath != "/api/cli/apps/acct/app/config" {
+ t.Fatalf("path = %q", gotPath)
+ }
+ if cfg.Bundle != "default" || cfg.ExecDelivery != "file" || cfg.SecretsPubkey != "abcdef==" {
+ t.Fatalf("config = %+v", cfg)
+ }
+ if len(cfg.Environments) != 1 || cfg.Environments[0] != "staging" {
+ t.Fatalf("environments = %v, want [staging]", cfg.Environments)
+ }
+ if len(cfg.Vars) != 2 {
+ t.Fatalf("vars = %+v, want 2 rows", cfg.Vars)
+ }
+ if cfg.Vars[0] != (apiConfigVar{Key: "PORT", Kind: "env", Value: "8080"}) {
+ t.Fatalf("vars[0] = %+v", cfg.Vars[0])
+ }
+ if cfg.Vars[1] != (apiConfigVar{Key: "BANK_KEY", Kind: "secret", Value: ""}) {
+ t.Fatalf("vars[1] = %+v, want a secret row with no value", cfg.Vars[1])
+ }
+}
+
+// TestAPISessionConfigGetAbsentFieldsDecodeEmpty pins the omitted-field
+// case: an empty app's response (no exec_delivery, no secrets_pubkey, per
+// handleAPIConfigGet's own doc comment) must decode to "" for both, not
+// error and not panic on a missing key.
+func TestAPISessionConfigGetAbsentFieldsDecodeEmpty(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{
+ "bundle": "default",
+ "environments": []string{},
+ "vars": []any{},
+ })
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ cfg, err := s.configGet(context.Background(), "acct", "app", "")
+ if err != nil {
+ t.Fatalf("configGet: %v", err)
+ }
+ if cfg.ExecDelivery != "" {
+ t.Fatalf("ExecDelivery = %q, want \"\" for an omitted field", cfg.ExecDelivery)
+ }
+ if cfg.SecretsPubkey != "" {
+ t.Fatalf("SecretsPubkey = %q, want \"\" for an omitted field", cfg.SecretsPubkey)
+ }
+ if len(cfg.Vars) != 0 {
+ t.Fatalf("Vars = %+v, want none", cfg.Vars)
+ }
+}
+
+// TestAPISessionConfigGetEnvironmentQueryParam pins the query-param rule:
+// environment=="" sends NO ?environment= at all (the server's own zero
+// value for "default bundle" is an absent parameter, not one carrying ""),
+// while a named environment sends it verbatim.
+func TestAPISessionConfigGetEnvironmentQueryParam(t *testing.T) {
+ var gotRawQuery string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotRawQuery = r.URL.RawQuery
+ json.NewEncoder(w).Encode(map[string]any{"bundle": "default", "environments": []string{}, "vars": []any{}})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ if _, err := s.configGet(context.Background(), "acct", "app", ""); err != nil {
+ t.Fatalf("configGet: %v", err)
+ }
+ if gotRawQuery != "" {
+ t.Fatalf("query for environment=\"\" = %q, want no query at all", gotRawQuery)
+ }
+
+ if _, err := s.configGet(context.Background(), "acct", "app", "staging"); err != nil {
+ t.Fatalf("configGet: %v", err)
+ }
+ if gotRawQuery != "environment=staging" {
+ t.Fatalf("query for environment=\"staging\" = %q, want environment=staging", gotRawQuery)
+ }
+}
+
+// TestAPISessionConfigGetNotFound pins the gate's byte-identical 404 —
+// http.NotFound's text/plain body, no JSON "error" field — reaching the
+// same generic membership message every other client method here falls
+// back to.
+func TestAPISessionConfigGetNotFound(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.configGet(context.Background(), "nope", "app", "")
+ if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
+ t.Fatalf("configGet 404 error = %v, want the membership message", err)
+ }
+}
+
+// TestAPISessionConfigApplyEnvVarBody pins the exact wire body configApply
+// sends for a plain env var set — appconfig.Var's OWN json tags produce
+// {"kind":"env","value":"8080"} with no "envelope" key (omitempty), so no
+// separate wire struct is needed in apimode.go.
+func TestAPISessionConfigApplyEnvVarBody(t *testing.T) {
+ var gotMethod, gotPath, gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMethod = r.Method
+ gotPath = r.URL.Path
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.configApply(context.Background(), "acct", "app", apiConfigApply{
+ Environment: "",
+ Set: map[string]appconfig.Var{"PORT": {Kind: "env", Value: "8080"}},
+ })
+ if err != nil {
+ t.Fatalf("configApply: %v", err)
+ }
+ if gotMethod != http.MethodPut {
+ t.Fatalf("method = %q, want PUT", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/acct/app/config" {
+ t.Fatalf("path = %q", gotPath)
+ }
+ if want := `{"environment":"","set":{"PORT":{"kind":"env","value":"8080"}}}`; gotBody != want {
+ t.Fatalf("body = %q, want %q", gotBody, want)
+ }
+}
+
+// TestAPISessionConfigApplySecretEnvelopeBody pins the secret-set body: a
+// sealed envelope, no "value" key (appconfig.Var's Value field omitempty).
+func TestAPISessionConfigApplySecretEnvelopeBody(t *testing.T) {
+ var gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.configApply(context.Background(), "acct", "app", apiConfigApply{
+ Environment: "",
+ Set: map[string]appconfig.Var{"BANK_KEY": {Kind: "secret", Envelope: "sealedbytes=="}},
+ })
+ if err != nil {
+ t.Fatalf("configApply: %v", err)
+ }
+ if want := `{"environment":"","set":{"BANK_KEY":{"kind":"secret","envelope":"sealedbytes=="}}}`; gotBody != want {
+ t.Fatalf("body = %q, want %q", gotBody, want)
+ }
+}
+
+// TestAPISessionConfigApplyUnsetBody pins an unset-only request: no "set"
+// key at all (map omitempty), "unset" carries the keys.
+func TestAPISessionConfigApplyUnsetBody(t *testing.T) {
+ var gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 1})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ removed, err := s.configApply(context.Background(), "acct", "app", apiConfigApply{
+ Environment: "",
+ Unset: []string{"OLD_KEY", "ALSO_MISSING"},
+ })
+ if err != nil {
+ t.Fatalf("configApply: %v", err)
+ }
+ if want := `{"environment":"","unset":["OLD_KEY","ALSO_MISSING"]}`; gotBody != want {
+ t.Fatalf("body = %q, want %q", gotBody, want)
+ }
+ // removed is the server's ACTUAL count, never the argument count — two
+ // keys were asked for, only one was really removed.
+ if removed != 1 {
+ t.Fatalf("removed = %d, want 1 (the server's count, not len(Unset)==2)", removed)
+ }
+}
+
+// TestAPISessionConfigApplyExecDeliveryPresent pins the exec_delivery
+// pointer's PRESENT case: the field appears on the wire with the exact
+// string the pointer carries — "off" here, never "" (see runEnvExecDelivery
+// in appconfig_cli.go — the CLI itself never sends "").
+func TestAPISessionConfigApplyExecDeliveryPresent(t *testing.T) {
+ var gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ off := "off"
+ if _, err := s.configApply(context.Background(), "acct", "app", apiConfigApply{ExecDelivery: &off}); err != nil {
+ t.Fatalf("configApply: %v", err)
+ }
+ if want := `{"environment":"","exec_delivery":"off"}`; gotBody != want {
+ t.Fatalf("body = %q, want %q", gotBody, want)
+ }
+}
+
+// TestAPISessionConfigApplyExecDeliveryAbsent pins the ABSENT case: a nil
+// pointer omits "exec_delivery" from the wire entirely (omitempty on the
+// pointer field) — "no change requested", distinct from an explicit "off".
+func TestAPISessionConfigApplyExecDeliveryAbsent(t *testing.T) {
+ var gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ b, _ := io.ReadAll(r.Body)
+ gotBody = string(b)
+ json.NewEncoder(w).Encode(map[string]any{"result": "ok", "removed": 0})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ if _, err := s.configApply(context.Background(), "acct", "app",
+ apiConfigApply{Set: map[string]appconfig.Var{"K": {Kind: "env", Value: "v"}}}); err != nil {
+ t.Fatalf("configApply: %v", err)
+ }
+ if strings.Contains(gotBody, "exec_delivery") {
+ t.Fatalf("body = %q, must not carry exec_delivery when the pointer is nil", gotBody)
+ }
+}
+
+// TestAPISessionConfigApplyNotFound pins the gate's generic 404 message,
+// mirroring every other client method's fallback.
+func TestAPISessionConfigApplyNotFound(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.configApply(context.Background(), "nope", "app", apiConfigApply{Unset: []string{"K"}})
+ if err == nil || !strings.Contains(err.Error(), "not found") || !strings.Contains(err.Error(), "-account") {
+ t.Fatalf("configApply 404 error = %v, want the membership message", err)
+ }
+}
+
+// TestAPISessionConfigApplyErrorBody pins a real JSON diagnosis (a CAS
+// conflict, say) surfacing verbatim, not folded into the generic message.
+func TestAPISessionConfigApplyErrorBody(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]string{"error": "config changed concurrently — try again"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, err := s.configApply(context.Background(), "acct", "app", apiConfigApply{Unset: []string{"K"}})
+ if err == nil || !strings.Contains(err.Error(), "config changed concurrently") {
+ t.Fatalf("configApply 409 error = %v, want the server's own message", err)
+ }
+}
+
+// TestAPISessionConfigSync pins the sync door's method/path and success.
+func TestAPISessionConfigSync(t *testing.T) {
+ var gotMethod, gotPath string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotMethod = r.Method
+ gotPath = r.URL.Path
+ json.NewEncoder(w).Encode(map[string]string{"result": "requested"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ if err := s.configSync(context.Background(), "acct", "app"); err != nil {
+ t.Fatalf("configSync: %v", err)
+ }
+ if gotMethod != http.MethodPost {
+ t.Fatalf("method = %q, want POST", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/acct/app/config/sync" {
+ t.Fatalf("path = %q", gotPath)
+ }
+}
+
+// TestAPISessionConfigSyncError pins error passthrough on the sync door.
+func TestAPISessionConfigSyncError(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": "could not store the config — try again"})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ err := s.configSync(context.Background(), "acct", "app")
+ if err == nil || !strings.Contains(err.Error(), "could not store the config") {
+ t.Fatalf("configSync error = %v, want the server's message", err)
+ }
+}
diff --git a/appconfig_cli.go b/appconfig_cli.go
index 749f32fc7..5d6aa0ec2 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -26,6 +26,7 @@ commands:
list -app <a> [-environment <name>] list an app's config vars
environments -app <a> list an app's named environment bundles
exec-delivery -app <a> file|off opt an app's TCP-addr exec routes into config delivery (issue #18)
+ sync -app <a> request immediate config convergence (issue #44)
-environment names a bundle at apps/<acct>/<app>/environments/<name>.json,
layered on top of the default bundle (config.json) at materialization time —
@@ -40,6 +41,17 @@ exactly as it already does for socket-exec routes, but those routes are
NEVER bounced: carlos does not own the process, so the app's own supervisor
owns the restart. "off" clears the field; a plain "env list" mentions it
when set.
+
+sync touches apps/<acct>/<app>/config-gen.json — the agent's fast poll loop
+(host package) notices within ~3s and re-renders exec-backed instances;
+unit-backed routes still wait for the next root adopt pass. It never
+restarts a box's shared edge service, only the one app's own delivery.
+
+Every command above also works through a logged-in console
+(carlos auth login) when CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are
+unset — [-console <origin>] picks which logged-in console when more than one
+is configured; the store env vars win outright when set (direct-bucket
+mode, unchanged).
`
// RunEnv is the `carlos env` verb.
@@ -59,6 +71,8 @@ func RunEnv(args []string) {
runEnvEnvironments(args[1:])
case "exec-delivery":
runEnvExecDelivery(args[1:])
+ case "sync":
+ runEnvSync(args[1:])
default:
fmt.Fprint(os.Stderr, envUsage)
os.Exit(1)
@@ -177,16 +191,36 @@ func runEnvSet(args []string) {
fs := flag.NewFlagSet("env set", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)")
- _, acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos env set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]")
+ log.Fatal("usage: carlos env set -app <a> [-environment <name>] [-console <origin>] KEY=value [KEY2=value2 ...]")
}
checkEnvironmentFlag(*environment)
kv, err := parseKV(fs.Args())
if err != nil {
log.Fatalf("env set: %v", err)
}
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ set := make(map[string]appconfig.Var, len(kv))
+ for k, v := range kv {
+ set[k] = appconfig.Var{Kind: "env", Value: v}
+ }
+ if _, err := sess.configApply(context.Background(), *acctRaw, *app,
+ apiConfigApply{Environment: *environment, Set: set}); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("set %d env var(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
+ return
+ }
+
if err := saveEnvVars(context.Background(), openStore(), acctOf(), *app, *environment, kv); err != nil {
log.Fatal(err)
}
@@ -197,12 +231,29 @@ func runEnvUnset(args []string) {
fs := flag.NewFlagSet("env unset", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)")
- _, acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos env unset -app <a> [-environment <name>] KEY [KEY2 ...]")
+ log.Fatal("usage: carlos env unset -app <a> [-environment <name>] [-console <origin>] KEY [KEY2 ...]")
}
checkEnvironmentFlag(*environment)
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ removed, err := sess.configApply(context.Background(), *acctRaw, *app,
+ apiConfigApply{Environment: *environment, Unset: fs.Args()})
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("unset %d of %d var(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment))
+ return
+ }
+
removed, err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args())
if err != nil {
log.Fatal(err)
@@ -214,36 +265,78 @@ func runEnvList(args []string) {
fs := flag.NewFlagSet("env list", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)")
- _, acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos env list -app <a> [-environment <name>]")
+ log.Fatal("usage: carlos env list -app <a> [-environment <name>] [-console <origin>]")
}
checkEnvironmentFlag(*environment)
- m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment)
+
+ sess, err := resolveMode(*consoleFlag)
if err != nil {
log.Fatal(err)
}
- fmt.Printf("%s (%s):\n", *app, bundleLabel(*environment))
- // exec_delivery is app-level only (read from the default bundle
- // alone — see appconfig.Manifest.ExecDelivery) and never printed for
- // a named environment's listing, matching where it can ever actually
- // be SET (runEnvExecDelivery refuses -environment before it reaches
- // appconfig at all).
- if *environment == "" && m.ExecDelivery != "" {
- fmt.Printf("exec-delivery: %s\n", m.ExecDelivery)
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ cfg, err := sess.configGet(context.Background(), *acctRaw, *app, *environment)
+ if err != nil {
+ log.Fatal(err)
+ }
+ printEnvList(*app, *environment, cfg.ExecDelivery, envVarsFromAPI(cfg.Vars))
+ return
+ }
+
+ m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment)
+ if err != nil {
+ log.Fatal(err)
}
- var keys []string
+ envVars := map[string]string{}
for k, v := range m.Vars {
if v.Kind == "env" {
- keys = append(keys, k)
+ envVars[k] = v.Value
}
}
+ printEnvList(*app, *environment, m.ExecDelivery, envVars)
+}
+
+// envVarsFromAPI narrows an apiConfig's Vars to the kind=="env" rows, keyed
+// by name — configGet's response shape for the API branch of runEnvList,
+// matching the direct branch's own map[key]value shape so printEnvList
+// prints identically regardless of mode.
+func envVarsFromAPI(vars []apiConfigVar) map[string]string {
+ out := map[string]string{}
+ for _, v := range vars {
+ if v.Kind == "env" {
+ out[v.Key] = v.Value
+ }
+ }
+ return out
+}
+
+// printEnvList renders `env list`'s output — header line, an exec-delivery
+// line when set and only for the default bundle (app-level only — see
+// appconfig.Manifest.ExecDelivery), then a sorted KEY/VALUE tabwriter —
+// byte-identical between the direct and API branches of runEnvList, which
+// differ only in HOW they fetch execDelivery/vars.
+func printEnvList(app, environment, execDelivery string, vars map[string]string) {
+ fmt.Printf("%s (%s):\n", app, bundleLabel(environment))
+ // exec_delivery is app-level only and never printed for a named
+ // environment's listing, matching where it can ever actually be SET
+ // (runEnvExecDelivery refuses -environment before it reaches appconfig
+ // at all).
+ if environment == "" && execDelivery != "" {
+ fmt.Printf("exec-delivery: %s\n", execDelivery)
+ }
+ var keys []string
+ for k := range vars {
+ keys = append(keys, k)
+ }
sort.Strings(keys)
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, "KEY\tVALUE")
for _, k := range keys {
- fmt.Fprintf(w, "%s\t%s\n", k, m.Vars[k].Value)
+ fmt.Fprintf(w, "%s\t%s\n", k, vars[k])
}
w.Flush()
}
@@ -264,10 +357,11 @@ func runEnvExecDelivery(args []string) {
// below. There is no environment-scoped variant to route it to:
// appconfig.SetExecDelivery has no environment parameter at all.
environment := fs.String("environment", "", "refused: exec_delivery is app-level only (default bundle), never a named environment")
- _, acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() != 1 {
- log.Fatal("usage: carlos env exec-delivery -app <a> file|off")
+ log.Fatal("usage: carlos env exec-delivery -app <a> [-console <origin>] file|off")
}
if *environment != "" {
log.Fatal(`carlos env exec-delivery: -environment is refused — exec_delivery is APP-LEVEL ONLY, read from the ` +
@@ -286,7 +380,26 @@ func runEnvExecDelivery(args []string) {
default:
log.Fatalf("carlos env exec-delivery: %q is not a valid value — want %q or \"off\"", fs.Arg(0), appconfig.ExecDeliveryFile)
}
- if err := appconfig.SetExecDelivery(context.Background(), openStore(), acctOf(), *app, value); err != nil {
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ // The wire vocabulary is "file"/"off", never "" — value is "" for
+ // the local SetExecDelivery convention (clearing the field), but
+ // the console's PUT decodes ExecDelivery as a pointer where nil
+ // means "no change", so clearing it must still be spelled "off".
+ wire := "off"
+ if value != "" {
+ wire = value
+ }
+ if _, err := sess.configApply(context.Background(), *acctRaw, *app,
+ apiConfigApply{ExecDelivery: &wire}); err != nil {
+ log.Fatal(err)
+ }
+ } else if err := appconfig.SetExecDelivery(context.Background(), openStore(), acctOf(), *app, value); err != nil {
log.Fatal(err)
}
if value == "" {
@@ -299,11 +412,29 @@ func runEnvExecDelivery(args []string) {
func runEnvEnvironments(args []string) {
fs := flag.NewFlagSet("env environments", flag.ExitOnError)
app := fs.String("app", "", "app name")
- _, acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos env environments -app <a>")
+ log.Fatal("usage: carlos env environments -app <a> [-console <origin>]")
}
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ cfg, err := sess.configGet(context.Background(), *acctRaw, *app, "")
+ if err != nil {
+ log.Fatal(err)
+ }
+ for _, n := range cfg.Environments {
+ fmt.Println(n)
+ }
+ return
+ }
+
names, err := appconfig.ListEnvironments(context.Background(), openStore(), acctOf(), *app)
if err != nil {
log.Fatal(err)
@@ -313,6 +444,41 @@ func runEnvEnvironments(args []string) {
}
}
+// runEnvSync is `carlos env sync` (issue #44) — the escape hatch for a
+// config write that needs to reach a running instance faster than
+// configSyncLoop's hourly floor: it touches apps/<acct>/<app>/config-gen.json
+// (appconfig.GenKey), which the agent's configGenLoop polls every ~3s and
+// treats a moved etag as "run SyncExec now". It never restarts a box's
+// shared edge service and never targets a bundle directly — the generation
+// object covers every bundle an app has (default plus every environment) in
+// one touch, matching TouchGeneration's own "app granularity, not
+// per-bundle" design (generation.go).
+func runEnvSync(args []string) {
+ fs := flag.NewFlagSet("env sync", flag.ExitOnError)
+ app := fs.String("app", "", "app name")
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ if *app == "" {
+ log.Fatal("usage: carlos env sync -app <a> [-account <sqid-or-name>] [-console <origin>]")
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ if err := sess.configSync(context.Background(), *acctRaw, *app); err != nil {
+ log.Fatal(err)
+ }
+ } else if err := appconfig.TouchGeneration(context.Background(), openStore(), acctOf(), *app); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("config sync requested for %s — exec-backed instances converge within seconds; "+
+ "unit-backed routes on the next root adopt pass\n", *app)
+}
+
const secretsUsage = `usage: carlos secrets <command>
commands:
@@ -325,6 +491,16 @@ commands:
see "carlos env" for the full explanation; the same bundle backs both env
vars and secrets, sealed or not. "carlos env environments -app <a>" lists
an app's bundle names.
+
+set/unset/list also work through a logged-in console (carlos auth login)
+when CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are unset —
+[-console <origin>] picks which logged-in console when more than one is
+configured; the store env vars win outright when set (direct-bucket mode,
+unchanged). In that mode "secrets set" seals with CARLOS_SECRETS_PUBKEY if
+it's set in this terminal's own environment, otherwise the console's own
+served public key (Config.SecretsPub) if it has one — CARLOS_SECRETS_KEY
+(the PRIVATE half) never leaves the box holding it either way. genkey is
+always local, in both modes.
`
// RunSecrets is the `carlos secrets` verb.
@@ -358,31 +534,91 @@ func secretsPubKey() *ecdh.PublicKey {
return k
}
+// secretsPubKeyAPI resolves the sealing key for API-mode `secrets set`:
+// CARLOS_SECRETS_PUBKEY in THIS terminal's own environment wins — matching
+// secretsPubKey's direct-mode rule exactly, so the same env var works
+// whichever mode a script happens to run in — otherwise the console's own
+// served public key (Config.SecretsPub, GET config's "secrets_pubkey"
+// field). That fallback is what makes `carlos auth login` alone sufficient
+// for `secrets set` (spec decision 2): public material by design, it only
+// encrypts. Neither present: a fatal naming both fixes — CARLOS_SECRETS_KEY
+// (the PRIVATE half) never leaves the box holding it, so there is no third
+// way to get a key here.
+func secretsPubKeyAPI(sess *apiSession, account, app string) *ecdh.PublicKey {
+ if raw := os.Getenv("CARLOS_SECRETS_PUBKEY"); raw != "" {
+ k, err := appconfig.ParsePublicKey(raw, "CARLOS_SECRETS_PUBKEY")
+ if err != nil {
+ log.Fatal(err)
+ }
+ return k
+ }
+ cfg, err := sess.configGet(context.Background(), account, app, "")
+ if err != nil {
+ log.Fatal(err)
+ }
+ if cfg.SecretsPubkey == "" {
+ log.Fatal("no CARLOS_SECRETS_PUBKEY in env and this console does not serve one — " +
+ "set the env var, or set CARLOS_SECRETS_PUBKEY in the console's environment")
+ }
+ k, err := appconfig.ParsePublicKey(cfg.SecretsPubkey, "console secrets_pubkey")
+ if err != nil {
+ log.Fatal(err)
+ }
+ return k
+}
+
func runSecretsSet(args []string) {
fs := flag.NewFlagSet("secrets set", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to set in (omit for the default bundle)")
- _, acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos secrets set -app <a> [-environment <name>] KEY=value [KEY2=value2 ...]")
+ log.Fatal("usage: carlos secrets set -app <a> [-environment <name>] [-console <origin>] KEY=value [KEY2=value2 ...]")
}
checkEnvironmentFlag(*environment)
kv, err := parseKV(fs.Args())
if err != nil {
log.Fatalf("secrets set: %v", err)
}
- pub := secretsPubKey()
- sealed := make(map[string]string, len(kv))
+ // Validate every value while the plaintext is still in hand, before
+ // either branch below seals anything — after Seal the value is opaque
+ // forever (`secrets list` never shows values), so a bad one would only
+ // surface as a render failure in the hourly adopt log, stalling the
+ // app's entire config with nothing for the operator to inspect.
for k, v := range kv {
- // Validate here, while the plaintext is still in hand — after Seal
- // the value is opaque forever (`secrets list` never shows values), so
- // a bad one would only surface as a render failure in the hourly
- // adopt log, stalling the app's entire config with nothing for the
- // operator to inspect.
if err := appconfig.ValidVarValue(v); err != nil {
log.Fatalf("secrets set: %s: %v", k, err)
}
+ }
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ pub := secretsPubKeyAPI(sess, *acctRaw, *app)
+ set := make(map[string]appconfig.Var, len(kv))
+ for k, v := range kv {
+ env, err := appconfig.Seal(pub, []byte(v))
+ if err != nil {
+ log.Fatalf("seal %s: %v", k, err)
+ }
+ set[k] = appconfig.Var{Kind: "secret", Envelope: env}
+ }
+ if _, err := sess.configApply(context.Background(), *acctRaw, *app,
+ apiConfigApply{Environment: *environment, Set: set}); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("set %d secret(s) for %s (%s)\n", len(kv), *app, bundleLabel(*environment))
+ return
+ }
+
+ pub := secretsPubKey()
+ sealed := make(map[string]string, len(kv))
+ for k, v := range kv {
env, err := appconfig.Seal(pub, []byte(v))
if err != nil {
log.Fatalf("seal %s: %v", k, err)
@@ -399,12 +635,29 @@ func runSecretsUnset(args []string) {
fs := flag.NewFlagSet("secrets unset", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to remove from (omit for the default bundle)")
- _, acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" || fs.NArg() == 0 {
- log.Fatal("usage: carlos secrets unset -app <a> [-environment <name>] KEY [KEY2 ...]")
+ log.Fatal("usage: carlos secrets unset -app <a> [-environment <name>] [-console <origin>] KEY [KEY2 ...]")
}
checkEnvironmentFlag(*environment)
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ removed, err := sess.configApply(context.Background(), *acctRaw, *app,
+ apiConfigApply{Environment: *environment, Unset: fs.Args()})
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Printf("unset %d of %d secret(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment))
+ return
+ }
+
removed, err := unsetVars(context.Background(), openStore(), acctOf(), *app, *environment, fs.Args())
if err != nil {
log.Fatal(err)
@@ -412,31 +665,61 @@ func runSecretsUnset(args []string) {
fmt.Printf("unset %d of %d secret(s) for %s (%s)\n", removed, fs.NArg(), *app, bundleLabel(*environment))
}
+// printSecretsList renders `secrets list`'s output — header line, then
+// sorted key names only, never values — byte-identical between the direct
+// and API branches of runSecretsList, which differ only in HOW they fetch
+// the key set.
+func printSecretsList(app, environment string, keys []string) {
+ fmt.Printf("%s (%s):\n", app, bundleLabel(environment))
+ sort.Strings(keys)
+ for _, k := range keys {
+ fmt.Println(k)
+ }
+}
+
func runSecretsList(args []string) {
fs := flag.NewFlagSet("secrets list", flag.ExitOnError)
app := fs.String("app", "", "app name")
environment := fs.String("environment", "", "named environment bundle to list (omit for the default bundle)")
- _, acctOf := accountFlags(fs)
+ acctRaw, acctOf := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to work through (API mode only; default: the only logged-in one)")
fs.Parse(args)
if *app == "" {
- log.Fatal("usage: carlos secrets list -app <a> [-environment <name>]")
+ log.Fatal("usage: carlos secrets list -app <a> [-environment <name>] [-console <origin>]")
}
checkEnvironmentFlag(*environment)
+
+ sess, err := resolveMode(*consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess != nil {
+ fmt.Fprintf(os.Stdout, "using %s as %s\n", sess.console, sess.address)
+ cfg, err := sess.configGet(context.Background(), *acctRaw, *app, *environment)
+ if err != nil {
+ log.Fatal(err)
+ }
+ var keys []string
+ for _, v := range cfg.Vars {
+ if v.Kind == "secret" {
+ keys = append(keys, v.Key)
+ }
+ }
+ printSecretsList(*app, *environment, keys)
+ return
+ }
+
m, err := loadBundle(context.Background(), openStore(), acctOf(), *app, *environment)
if err != nil {
log.Fatal(err)
}
- fmt.Printf("%s (%s):\n", *app, bundleLabel(*environment))
var keys []string
for k, v := range m.Vars {
if v.Kind == "secret" {
keys = append(keys, k)
}
}
- sort.Strings(keys)
- for _, k := range keys {
- fmt.Println(k)
- }
+ printSecretsList(*app, *environment, keys)
}
func runSecretsGenkey(args []string) {