From 5f40ab8637d6ad1e7ef8175a74b16f2bc7676077 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 28 Oct 2026 16:38:20 -0400 Subject: [PATCH 01/13] fix(logs): complete JSON stream contracts and filtering (D-0750) --- .github/facts.json | 2 +- .github/openapi.json | 25 ---- cmd/commands/json_io_test.go | 117 +++++++++++++++++- cmd/commands/logs_json.go | 6 +- cmd/commands/logs_stream.go | 8 +- internal/canon/domains/io.yaml | 4 +- internal/canon/effective_test.go | 2 +- internal/invoke/expose_json_test.go | 15 +++ internal/invoke/schema.go | 1 + ....json => admin-logs.record.v1.schema.json} | 2 +- ...schema.json => logs.record.v1.schema.json} | 2 +- schemas/index.json | 8 +- tools/schemagen/spec.go | 2 +- tools/schemagen/stream_records.go | 9 +- 14 files changed, 152 insertions(+), 51 deletions(-) rename schemas/commands/{admin-logs-record.v1.schema.json => admin-logs.record.v1.schema.json} (85%) rename schemas/commands/{logs-record.v1.schema.json => logs.record.v1.schema.json} (89%) diff --git a/.github/facts.json b/.github/facts.json index 91818ddc4..4e308c6fc 100644 --- a/.github/facts.json +++ b/.github/facts.json @@ -9,7 +9,7 @@ }, "schema_version": "1", "sources": { - "openapi_sha256": "01ea16e2ac6cebd687983d758b80a9f1ddffe3b6a3a85bda4fc70ce077ca71b6", + "openapi_sha256": "28a28a8b94fc4bb8da75afb5e7d191c153b52897a0c197665d6d9fa87238b0c7", "registry_sha256": "37fe98fe4dfa26f0c8af15f5cc50e9a5b81f499976850ae2b6066c536e8fe6c9" }, "verbs": [ diff --git a/.github/openapi.json b/.github/openapi.json index 98081f1ba..6d7cc7ce1 100644 --- a/.github/openapi.json +++ b/.github/openapi.json @@ -42303,11 +42303,6 @@ "flags": { "additionalProperties": false, "properties": { - "ai": { - "default": false, - "description": "Run the AI first-run wizard (install Ollama, setup Gemini pool, verify)", - "type": "boolean" - }, "alerts": { "default": false, "description": "Check monitoring alert rules are loaded", @@ -42348,11 +42343,6 @@ "description": "Print OAuth URL instead of opening browser (for SSH/headless servers)", "type": "boolean" }, - "install-check": { - "default": false, - "description": "Run 6-stage onboarding funnel check (used by Homebrew post-install hook)", - "type": "boolean" - }, "only": { "default": "", "description": "Run only a specific subsystem check (host, docker, postgres, hasura, nginx, ssl, ping, plugins, license, monitoring, backups, security)", @@ -47168,26 +47158,11 @@ "description": "Show logs since (e.g. 1h, 30m, or RFC3339)", "type": "string" }, - "status": { - "default": false, - "description": "Show service status overview", - "type": "boolean" - }, - "summary": { - "default": false, - "description": "Show recent errors by service", - "type": "boolean" - }, "tail": { "default": 10, "description": "Number of lines to show", "type": "integer" }, - "top": { - "default": false, - "description": "Show most active services", - "type": "boolean" - }, "until": { "default": "", "description": "Show logs until (e.g. 1h or RFC3339)", diff --git a/cmd/commands/json_io_test.go b/cmd/commands/json_io_test.go index 80ecda485..8628d011f 100644 --- a/cmd/commands/json_io_test.go +++ b/cmd/commands/json_io_test.go @@ -68,8 +68,8 @@ func TestEnvelopeCoverageIO(t *testing.T) { t.Fatal(err) } for _, c := range old.Commands { - if c.Path == "nself logs" && c.JSON == canon.JSONEnvelope { - t.Fatalf("v1.4 logs json = %s, want no envelope", c.JSON) + if c.Path == "nself logs" && c.JSON != canon.JSONLegacy { + t.Fatalf("v1.4 logs json = %s, want legacy", c.JSON) } } } @@ -121,7 +121,7 @@ func ioProject(t *testing.T, lines []string, containers bool, logsExit int) { if containers { ps = "echo abc123; exit 0" } - stub := "#!/bin/sh\ncase \" $* \" in\n *' ps -q '*) " + ps + " ;;\n *' logs '*) cat '" + + stub := "#!/bin/sh\nprintf '%s\\n' \"$@\" > '" + filepath.Join(dir, "argv.txt") + "'\ncase \" $* \" in\n *' ps -q '*) " + ps + " ;;\n *' logs '*) cat '" + filepath.Join(dir, "lines.txt") + "'; exit " + itoa(logsExit) + " ;;\nesac\nexit 0\n" bin := t.TempDir() if err := os.WriteFile(filepath.Join(bin, "docker"), []byte(stub), 0o755); err != nil { @@ -227,7 +227,7 @@ func TestLogsJSON(t *testing.T) { if len(recs) != 5 { t.Fatalf("records = %d, want 5", len(recs)) } - rs := contractResolve(t, "commands/logs-record.v1.schema.json") + rs := contractResolve(t, "commands/logs.record.v1.schema.json") for i, r := range recs { raw, _ := json.Marshal(r) if err := contractValidate(rs, raw); err != nil { @@ -295,7 +295,7 @@ func TestLogsJSONStream(t *testing.T) { if len(docs) != 6 { t.Fatalf("lines = %d, want 5 records and 1 result", len(docs)) } - rs := contractResolve(t, "commands/logs-record.v1.schema.json") + rs := contractResolve(t, "commands/logs.record.v1.schema.json") for i, d := range docs[:5] { data, _ := d["data"].(map[string]any) if d["command"] != "logs" || data["type"] != "log" { @@ -450,3 +450,110 @@ func TestLogsJSONStreamLongLine(t *testing.T) { t.Fatalf("last line data = %v, want type result and lines 2", last) } } + +func TestLogsRecordPreservesIndentation(t *testing.T) { + for _, line := range []string{"web-1 | indented ", "web-1 | 2026-01-02T03:04:05Z indented "} { + if got := logRecordFromLine(line).Message; got != " indented " { + t.Fatalf("message = %q", got) + } + } +} + +func TestLogsJSONFollowLegacyBytes(t *testing.T) { + compattest.Set(t, false) + ioProject(t, ioLogLines, true, 0) + out, err := runIOLogsLegacy(t, "--json", "--follow") + if err != nil { + t.Fatal(err) + } + want := "" + for _, line := range ioLogLines { + want += `{"message":` + strconv.Quote(line) + "}\n" + } + // Legacy level detection runs on the entire prefixed line. + want = strings.Replace(want, `{"message":"web-1 | 2026-01-02T03:04:05Z`, `{"level":"info","message":"web-1 | 2026-01-02T03:04:05Z`, 1) + want = strings.Replace(want, `{"message":"db-1 | 2026-01-02T03:04:07Z`, `{"level":"error","message":"db-1 | 2026-01-02T03:04:07Z`, 1) + want = strings.Replace(want, `{"message":"db-1 | 2026-01-02T03:04:08Z`, `{"level":"warn","message":"db-1 | 2026-01-02T03:04:08Z`, 1) + want = strings.ReplaceAll(want, `started"}`, `started","service":"web-1 |"}`) + want = strings.ReplaceAll(want, `200"}`, `200","service":"web-1 |"}`) + want = strings.ReplaceAll(want, `refused"}`, `refused","service":"db-1 |"}`) + want = strings.ReplaceAll(want, `done"}`, `done","service":"db-1 |"}`) + want = strings.ReplaceAll(want, `timestamp"}`, `timestamp","service":"web-1 |"}`) + want = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(want, "<", `\u003c`), ">", `\u003e`), "&", `\u0026`) + if out != want { + t.Fatalf("legacy follow bytes changed: got %s want %s", out, want) + } +} + +func TestLogsJSONArgv(t *testing.T) { + compattest.Both(t, func(t *testing.T) { + for _, follow := range []bool{false, true} { + t.Run(strconv.FormatBool(follow), func(t *testing.T) { + ioProject(t, nil, true, 0) + flags := []string{"--json", "--tail", "5"} + want := []string{"compose", "logs"} + if follow { + flags = append(flags, "--follow") + want = append(want, "--follow") + } + want = append(want, "--tail", "5") + if !follow && !compat.V15() { + want = append(want, "--no-log-prefix") + } + if compat.V15() { + want = append(want, "--no-color") + } + var err error + if compat.V15() { + _, err = runIOLogs(t, flags...) + } else { + _, err = runIOLogsLegacy(t, flags...) + } + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(filepath.Join(ioDir, "argv.txt")) + if err != nil { + t.Fatal(err) + } + if string(got) != strings.Join(want, "\n")+"\n" { + t.Fatalf("argv = %s want %v", got, want) + } + }) + } + }) +} + +func TestLogsJSONOverlongLine(t *testing.T) { + compattest.Set(t, true) + ioProject(t, []string{strings.Repeat("x", maxLogLineBytes+1)}, true, 0) + out, err := runIOLogs(t, "--json", "--follow") + if err == nil { + t.Fatal("overlong line must fail") + } + docs := streamLines(t, out) + if len(docs) != 1 || docs[0]["error"] == nil { + t.Fatalf("want one terminal error: %s", out) + } +} + +func TestLogsJSONFiltersIgnoreServicePrefix(t *testing.T) { + compattest.Set(t, true) + for _, flag := range []string{"--search", "--grep", "--level"} { + t.Run(flag, func(t *testing.T) { + ioProject(t, []string{"error-service | [info] ordinary message"}, true, 0) + value := "error" + if flag == "--grep" { + value = "^error" + } + out, err := runIOLogs(t, "--json", flag, value) + if err != nil { + t.Fatal(err) + } + data := validateEnvelope(t, out, "logs") + if len(data["records"].([]any)) != 0 { + t.Fatalf("prefix affected filter: %s", out) + } + }) + } +} diff --git a/cmd/commands/logs_json.go b/cmd/commands/logs_json.go index 7eb163ff9..b7bed9d5d 100644 --- a/cmd/commands/logs_json.go +++ b/cmd/commands/logs_json.go @@ -22,6 +22,8 @@ import ( ) // composePrefixRE matches the " | " prefix docker compose puts on every line. +var recordTimestampPrefixRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?[ \t]?`) + var composePrefixRE = regexp.MustCompile(`^([^\s|]+)\s*\|\s?`) // logRecordFromLine splits one raw compose log line into its record: service @@ -33,11 +35,11 @@ func logRecordFromLine(line string) LogRecord { rec.Service = m[1] rest = rest[len(m[0]):] } - if ts := timestampPrefixRE.FindString(rest); ts != "" { + if ts := recordTimestampPrefixRE.FindString(rest); ts != "" { rec.Time = strings.TrimSpace(ts) rest = rest[len(ts):] } - rec.Message = strings.TrimSpace(rest) + rec.Message = rest rec.Level = detectLineLevel(rest) return rec } diff --git a/cmd/commands/logs_stream.go b/cmd/commands/logs_stream.go index 55169f1b8..7e7191af7 100644 --- a/cmd/commands/logs_stream.go +++ b/cmd/commands/logs_stream.go @@ -45,11 +45,15 @@ const maxLogLineBytes = 4 << 20 // parent context ends the stream without an error. func streamLogLines(parent context.Context, workdir string, composeArgs []string, opts LogsOptions, fn func(line string) error) error { return docker.StreamLines(parent, workdir, composeArgs, os.Stderr, false, maxLogLineBytes, func(line string) error { - filtered, ok := filterLogLine(line, opts) + text := line + if logsEnvelopeMode(opts.JSON) { + text = composePrefixRE.ReplaceAllString(line, "") + } + _, ok := filterLogLine(text, opts) if !ok { return nil } - return fn(filtered) + return fn(line) }) } diff --git a/internal/canon/domains/io.yaml b/internal/canon/domains/io.yaml index 3ac683d63..87e139bf6 100644 --- a/internal/canon/domains/io.yaml +++ b/internal/canon/domains/io.yaml @@ -4,5 +4,5 @@ schema_version: 1 commands: # cmd/commands/exec.go:40 runs an arbitrary user command in a service container (worst case) exec: {canon: core, side_effect: destructive, output: interactive} # runs a TTY command in a container: no envelope, not exposed on machine surfaces - # cmd/commands/logs.go: reads docker compose logs; --follow never ends by itself, so it is framed as NDJSON (contract:cli.json-stream v1, P7-SURF-27); --status/--summary/--top print human reports only; no json: legacy because v1.4 --json is a local flag the registry does not advertise (the per-line JSON is pinned by the legacy-json argv row), v1.5 prints the envelope - logs: {canon: core, side_effect: read, flags: {follow: {output: stream}, status: {json: none}, summary: {json: none}, top: {json: none}}} + # cmd/commands/logs.go: reads docker compose logs; --follow never ends by itself, so it is framed as NDJSON (contract:cli.json-stream v1, P7-SURF-27); --status/--summary/--top print human reports only; v1.4 advertises legacy per-line JSON, v1.5 registers an envelope + logs: {canon: core, side_effect: read, json: legacy, flags: {follow: {output: stream}, status: {json: none}, summary: {json: none}, top: {json: none}}} diff --git a/internal/canon/effective_test.go b/internal/canon/effective_test.go index 409bfe6c4..7879c70cd 100644 --- a/internal/canon/effective_test.go +++ b/internal/canon/effective_test.go @@ -194,7 +194,7 @@ func TestEffectivePresplitEquivalence(t *testing.T) { // stream, --status/--summary/--top are json none); the frozen // pre-split table says stream/legacy. got.Flags = nil - want.Output, want.JSON = "", "" + want.Output, want.JSON = "", JSONLegacy case "exec": // P7-SURF-27: exec is interactive (runs a TTY command in a container). want.Output = OutputInteractive diff --git a/internal/invoke/expose_json_test.go b/internal/invoke/expose_json_test.go index 4c985444a..c85bf4501 100644 --- a/internal/invoke/expose_json_test.go +++ b/internal/invoke/expose_json_test.go @@ -39,3 +39,18 @@ func TestExposureRefusesFlagJSONOverride(t *testing.T) { } } } + +func TestExposedFlagsExcludeNonEnvelopeJSON(t *testing.T) { + str := func(s string) *string { return &s } + cmd := &cmdregistry.Command{Flags: []cmdregistry.Flag{ + {Name: "status", Type: "bool", JSON: str(canon.JSONNone)}, + {Name: "legacy", Type: "bool", JSON: str(canon.JSONLegacy)}, + {Name: "tail", Type: "int"}, + }} + for _, transport := range []string{TransportMCP, TransportHTTP, TransportHTTPStream} { + flags := ExposedFlags(cmd, transport) + if len(flags) != 1 || flags[0].Name != "tail" { + t.Fatalf("%s flags = %v", transport, flags) + } + } +} diff --git a/internal/invoke/schema.go b/internal/invoke/schema.go index e50d80c0c..d4a4adfa2 100644 --- a/internal/invoke/schema.go +++ b/internal/invoke/schema.go @@ -82,6 +82,7 @@ func ExposedFlags(cmd *cmdregistry.Command, transport string) []cmdregistry.Flag stream := f.Output != nil && *f.Output == canon.OutputStream switch { case invokerFlags[f.Name], owned[f.Name], f.Hidden, f.Deprecated != nil, f.CLIOnly: + case f.JSON != nil && *f.JSON != canon.JSONEnvelope: case stream && transport != TransportHTTPStream: case flagKind(f.Type) == "": default: diff --git a/schemas/commands/admin-logs-record.v1.schema.json b/schemas/commands/admin-logs.record.v1.schema.json similarity index 85% rename from schemas/commands/admin-logs-record.v1.schema.json rename to schemas/commands/admin-logs.record.v1.schema.json index 227ad9d81..622639748 100644 --- a/schemas/commands/admin-logs-record.v1.schema.json +++ b/schemas/commands/admin-logs.record.v1.schema.json @@ -1,6 +1,6 @@ { "$comment": "GENERATED BY tools/schemagen — DO NOT HAND EDIT", - "$id": "urn:nself:cli:schema:commands:admin-logs-record:v1", + "$id": "urn:nself:cli:schema:commands:admin-logs.record:v1", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { diff --git a/schemas/commands/logs-record.v1.schema.json b/schemas/commands/logs.record.v1.schema.json similarity index 89% rename from schemas/commands/logs-record.v1.schema.json rename to schemas/commands/logs.record.v1.schema.json index 83bbb30b4..75ff20a37 100644 --- a/schemas/commands/logs-record.v1.schema.json +++ b/schemas/commands/logs.record.v1.schema.json @@ -1,6 +1,6 @@ { "$comment": "GENERATED BY tools/schemagen — DO NOT HAND EDIT", - "$id": "urn:nself:cli:schema:commands:logs-record:v1", + "$id": "urn:nself:cli:schema:commands:logs.record:v1", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, "properties": { diff --git a/schemas/index.json b/schemas/index.json index dc8e821da..bf2a0ec50 100644 --- a/schemas/index.json +++ b/schemas/index.json @@ -22,8 +22,8 @@ "id": "urn:nself:cli:schema:commands:admin-health:v1" }, { - "path": "commands/admin-logs-record.v1.schema.json", - "id": "urn:nself:cli:schema:commands:admin-logs-record:v1" + "path": "commands/admin-logs.record.v1.schema.json", + "id": "urn:nself:cli:schema:commands:admin-logs.record:v1" }, { "path": "commands/admin-logs.v1.schema.json", @@ -1098,8 +1098,8 @@ "id": "urn:nself:cli:schema:commands:license:v1" }, { - "path": "commands/logs-record.v1.schema.json", - "id": "urn:nself:cli:schema:commands:logs-record:v1" + "path": "commands/logs.record.v1.schema.json", + "id": "urn:nself:cli:schema:commands:logs.record:v1" }, { "path": "commands/logs.v1.schema.json", diff --git a/tools/schemagen/spec.go b/tools/schemagen/spec.go index c069a7772..71d7ba70b 100644 --- a/tools/schemagen/spec.go +++ b/tools/schemagen/spec.go @@ -41,7 +41,7 @@ type Spec struct { } // outPattern is the only accepted shape of Spec.Out. -var outPattern = regexp.MustCompile(`^[a-z0-9/_-]+\.v[0-9]+\.schema\.json$`) +var outPattern = regexp.MustCompile(`^[a-z0-9/_-]+(?:\.[a-z0-9_-]+)*\.v[0-9]+\.schema\.json$`) // typeOverrides holds overrides keyed by Go type; build applies them before a // spec's own Overrides, so every schema generated from the same type (for diff --git a/tools/schemagen/stream_records.go b/tools/schemagen/stream_records.go index b7d267f1f..75edcb327 100644 --- a/tools/schemagen/stream_records.go +++ b/tools/schemagen/stream_records.go @@ -1,11 +1,8 @@ package main // Purpose: one record schema per streaming command (contract:cli.json-stream -// v1, P7-SURF-27): schemas/commands/-record.v1.schema.json describes the -// data object of one record line (data.type names the kind). The contract text -// names .record.v1.schema.json, but spec.go outPattern (outside this -// Ticket's scope) rejects a dot in the name; recordOut is the one place to -// switch once the pattern allows it. +// v1, P7-SURF-27): schemas/commands/.record.v1.schema.json describes +// the data object of one record line (data.type names the kind). // Inputs: cmd/commands.JSONRecordTypes (path -> record zero value). A record // type with RecordType() string gets that value as the const of its type member. // Outputs: one Spec per path. @@ -23,7 +20,7 @@ type recordTyper interface{ RecordType() string } // recordOut is the schema path of a command's stream record type. func recordOut(path string) string { - return "commands/" + strings.ReplaceAll(path, " ", "-") + "-record.v1.schema.json" + return "commands/" + strings.ReplaceAll(path, " ", "-") + ".record.v1.schema.json" } func init() { From ac3967c575ac3dcf5ac54175523ed8dfeaede877 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 15 Oct 2026 13:24:08 -0400 Subject: [PATCH 02/13] fix(hasura): honor explicit version selections (D-0720) --- .github/wiki/Config-Env-Vars.md | 2 +- .github/wiki/Config-Hasura.md | 2 +- .github/wiki/Config-System.md | 2 +- .github/wiki/Image-Lock.md | 7 +- cmd/commands/hasura_version_init_test.go | 64 +++++++++++++++++++ cmd/commands/start_lock_pins_init_test.go | 4 +- .../testdata/json/human/config-list.txt | 2 +- internal/compose/core_services.go | 20 ++---- internal/compose/hasura_image_test.go | 18 +++--- internal/compose/image_inputs.go | 12 ++-- internal/compose/project_pins_test.go | 3 +- internal/config/defaults_postgres_hasura.go | 5 +- internal/config/exports.go | 2 +- internal/setup/setup_env_files.go | 6 +- 14 files changed, 102 insertions(+), 47 deletions(-) create mode 100644 cmd/commands/hasura_version_init_test.go diff --git a/.github/wiki/Config-Env-Vars.md b/.github/wiki/Config-Env-Vars.md index 006bfdc4e..4cb2abb52 100644 --- a/.github/wiki/Config-Env-Vars.md +++ b/.github/wiki/Config-Env-Vars.md @@ -186,7 +186,7 @@ See also `BACKUP_CRITICAL_TABLES` under [PostgreSQL](#postgresql), and | Variable | Type | Default | Required | Description | |---|---|---|---|---| -| `HASURA_VERSION` | string | `v2.44.0` | No | Docker image tag for Hasura. | +| `HASURA_VERSION` | string | blank | No | Blank keeps the project lock pin or mode default; any nonblank tag explicitly selects that release. | | `HASURA_GRAPHQL_ADMIN_SECRET` | string | *(none)* | **Yes** | Admin API secret used to authenticate privileged requests. Minimum 32 characters. | | `HASURA_JWT_KEY` | string | *(none)* | **Yes** | Secret key used to sign and verify JWTs. Minimum 32 characters. | | `HASURA_JWT_TYPE` | string | `HS256` | No | JWT signing algorithm. Common values: `HS256`, `RS256`. | diff --git a/.github/wiki/Config-Hasura.md b/.github/wiki/Config-Hasura.md index 652e11acb..94ed19d32 100644 --- a/.github/wiki/Config-Hasura.md +++ b/.github/wiki/Config-Hasura.md @@ -10,7 +10,7 @@ All Hasura configuration is driven by `HASURA_*` variables in your `.env` files. | Variable | Type | Default | Required | Description | |----------|------|---------|----------|-------------| -| `HASURA_VERSION` | string | `v2.44.0` | No | Docker image tag for the Hasura container | +| `HASURA_VERSION` | string | blank | No | Blank keeps the project lock pin, or uses v2.44.0 in v1.4 and v2.51.0 in v1.5. Any nonblank tag is explicit. | | `HASURA_GRAPHQL_ADMIN_SECRET` | string | *(none)* | **Yes** | Admin API secret (minimum 32 characters). Required to access the Hasura Console and the admin API endpoint. | | `HASURA_JWT_KEY` | string | *(none)* | **Yes** | JWT signing key (minimum 32 characters). Used to verify tokens issued by the Auth service. | | `HASURA_JWT_TYPE` | string | `HS256` | No | JWT algorithm. Supported values: `HS256`, `RS256`, and other algorithms supported by Hasura. | diff --git a/.github/wiki/Config-System.md b/.github/wiki/Config-System.md index ff50fc081..f8ba867ea 100644 --- a/.github/wiki/Config-System.md +++ b/.github/wiki/Config-System.md @@ -80,7 +80,7 @@ You do not need to set every variable. ɳSelf ships with sensible defaults so mi | `ENV` | `dev` | | `BASE_DOMAIN` | `local.nself.org` | | `POSTGRES_VERSION` | `16-alpine` | -| `HASURA_VERSION` | `v2.44.0` | +| `HASURA_VERSION` | blank (project lock pin or compatibility mode default) | Password handling varies by environment: diff --git a/.github/wiki/Image-Lock.md b/.github/wiki/Image-Lock.md index 598a52bb5..17f8dd29d 100644 --- a/.github/wiki/Image-Lock.md +++ b/.github/wiki/Image-Lock.md @@ -25,7 +25,7 @@ Set `IMAGE_PINNING=lock` in `.env` to opt a project in early. Moving to `lock` c ## Overrides -A `*_VERSION` key in the env cascade is an explicit override and is used as given, unpinned, unless it equals the lock version or the `legacy_ref` tag. The `legacy_ref` tag is what the config defaults and the `.env` files written by `nself setup` carry (`HASURA_VERSION=v2.44.0`), so it counts as "the default" and stays pinned after a bump. To run the pre-lock version itself, use `IMAGE_PINNING=legacy`. +A `*_VERSION` key in the env cascade is an explicit override and is used as given, unpinned, unless it equals the lock version or the `legacy_ref` tag. The `legacy_ref` tag is what most config defaults and init env files carry, so it counts as the default and stays pinned after a bump. Hasura leaves its version blank instead: every nonblank `HASURA_VERSION` is explicit. Known Hasura releases use their own lock entry; other tags render unpinned. To use tag references without digests, use `IMAGE_PINNING=legacy`. | Setting | `IMAGE_PINNING=lock` | `IMAGE_PINNING=legacy` | |---|---|---| @@ -101,10 +101,13 @@ The lock carries two Hasura entries on `docker.io/hasura/graphql-engine`: `hasur | v1.4 | `hasura-v14`: `docker.io/hasura/graphql-engine:v2.44.0@sha256:...` in lock mode, `hasura/graphql-engine:v2.44.0` in legacy mode | | v1.5, no lock pin yet (a new project) | `hasura` (v2.51.0) | | the project lock pins v2.44.0 | the pin, in both modes, until `nself update` moves it | -| `HASURA_VERSION=v2.44.0` (what `nself init` writes into `.env`) or blank | the mode default, or the pin if there is one: a default, not an override | +| `HASURA_VERSION=` (what `nself init` writes into `.env`) or unset | the mode default, or the pin if there is one | +| `HASURA_VERSION=v2.44.0` | `hasura-v14` in any mode; explicit, so a different project pin yields with one notice | | `HASURA_VERSION=v2.51.0` | `hasura` in any mode; it is an override, so it drops a lock pin of v2.44.0 with one notice | | any other `HASURA_VERSION` (`v2.45.0`) | `hasura/graphql-engine:`, unpinned, in any mode | +Existing env files containing `HASURA_VERSION=v2.44.0` now explicitly select that release. Clear the value to follow the project's lock pin or mode default. `nself service upgrade hasura v2.44.0` writes an explicit selection and takes effect on the next build. + Hasura v2.51.0 does not change the metadata format or the migration contract. `scripts/ci/hasura-upgrade-smoke.sh` starts a project on v2.44.0 with a migrated fixture (tables and rows) and metadata (tracked tables, a relationship, a select permission, an event trigger to a webhook, a remote schema), takes a verified backup, switches the pin to v2.51.0 and checks that the rows are readable, that the metadata export is identical before and after (the `nself db hasura metadata export` tree and the normalised API JSON), that apply then export round-trips, and that a mutation, a subscription event, a remote schema call and an event trigger delivery work. The metadata catalog version is 48 on both releases. ### Upgrade an existing project diff --git a/cmd/commands/hasura_version_init_test.go b/cmd/commands/hasura_version_init_test.go new file mode 100644 index 000000000..25a0a22e4 --- /dev/null +++ b/cmd/commands/hasura_version_init_test.go @@ -0,0 +1,64 @@ +package commands + +// Purpose: distinguish mode defaults from explicit Hasura versions (D-0720). +// Inputs: real init env files and the service upgrade handler in both modes. +// Outputs: rendered images, including overrides of an existing project pin. +// Constraints: no Docker or network; use the production config loader. + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nself-org/cli/internal/compat/compattest" + "github.com/nself-org/cli/internal/compose" + "github.com/nself-org/cli/internal/setup" + "github.com/spf13/cobra" +) + +func TestHasuraVersionExplicitAfterInit(t *testing.T) { + compattest.Both(t, func(t *testing.T) { + for _, demo := range []bool{false, true} { + for _, mode := range []string{"legacy", "lock"} { + t.Run(fmt.Sprintf("demo=%v/%s", demo, mode), func(t *testing.T) { + blankImageVars(t) + t.Setenv("IMAGE_PINNING", mode) + dir := t.TempDir() + t.Chdir(dir) + if _, err := setup.Initialize(setup.Options{WorkDir: dir, Name: "hasurapin", Demo: demo, NonInteractive: true, Quiet: true}); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(dir, ".env")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "\nHASURA_VERSION=\n") { + t.Error("init must leave HASURA_VERSION blank for the mode default") + } + defer compose.SetProjectPins(nil)() + wantDefault := compose.ImageRef(compose.HasuraEntry(), "") + if got := renderStackIn(t, dir, nil)["hasura"]; got != wantDefault { + t.Errorf("init image = %s, want %s", got, wantDefault) + } + // A newer project pin must yield to an explicit downgrade, too. + for _, pinned := range []bool{false, true} { + if pinned { + compose.SetProjectPins(map[string]string{"hasura": compose.ImageRef("hasura", "")}) + } + cmd := &cobra.Command{} + cmd.Flags().String("env", "", "") + if err := runServiceUpgrade(cmd, []string{"hasura", "v2.44.0"}); err != nil { + t.Fatal(err) + } + want := compose.ImageRef("hasura-v14", "") + if got := renderStackIn(t, dir, nil)["hasura"]; got != want { + t.Errorf("upgrade v2.44.0 (pinned=%v) image = %s, want %s", pinned, got, want) + } + } + }) + } + } + }) +} diff --git a/cmd/commands/start_lock_pins_init_test.go b/cmd/commands/start_lock_pins_init_test.go index b79e66821..12ea92d32 100644 --- a/cmd/commands/start_lock_pins_init_test.go +++ b/cmd/commands/start_lock_pins_init_test.go @@ -78,9 +78,9 @@ func TestProjectLockPinsSurviveInitDefaults(t *testing.T) { if err != nil { t.Fatal(err) } - edited := strings.Replace(string(raw), "HASURA_VERSION=v2.44.0", "HASURA_VERSION=v2.51.0", 1) + edited := strings.Replace(string(raw), "HASURA_VERSION=\n", "HASURA_VERSION=v2.51.0\n", 1) if edited == string(raw) { - t.Fatal("init no longer writes HASURA_VERSION=v2.44.0: update this test with the new default") + t.Fatal("init must leave HASURA_VERSION blank") } if err := os.WriteFile(envPath, []byte(edited), 0o600); err != nil { t.Fatal(err) diff --git a/cmd/commands/testdata/json/human/config-list.txt b/cmd/commands/testdata/json/human/config-list.txt index 60989669f..41db653b4 100644 --- a/cmd/commands/testdata/json/human/config-list.txt +++ b/cmd/commands/testdata/json/human/config-list.txt @@ -28,7 +28,7 @@ PGVECTOR_ENABLED (unset) uns PGVECTOR_DIMENSIONS (unset) unset PGVECTOR_HNSW_M (unset) unset PGVECTOR_HNSW_EF_CONSTRUCTION (unset) unset -HASURA_VERSION (default: v2.44.0) default +HASURA_VERSION (unset) unset HASURA_GRAPHQL_ADMIN_SECRET (unset) unset HASURA_ADMIN_SECRET (unset) unset HASURA_JWT_KEY (unset) unset diff --git a/internal/compose/core_services.go b/internal/compose/core_services.go index 68a307d62..9b868323e 100644 --- a/internal/compose/core_services.go +++ b/internal/compose/core_services.go @@ -216,25 +216,19 @@ func HasuraEntry() string { return "hasura-v14" } -// hasuraImage renders the hasura image for the config's HASURA_VERSION. Blank -// or v2.44.0 (the config default nself init writes into .env) is no override -// and renders the HasuraEntry entry: v2.44.0 in v1.4, v2.51.0 in v1.5 (in v1.5 -// without a lock pin, v2.44.0 is the init default and renders hasura). The -// current release's own version (v2.51.0) renders hasura in any mode and, unlike -// the init default, is an override (image_inputs.go NotInitDefault). A project -// lock pin wins over the defaults, so a v1.5 project pinned to v2.44.0 stays on -// it until `nself update`. Any other value is an explicit tag, used as given, -// unpinned. +// hasuraImage renders HASURA_VERSION. Blank selects the mode default, yielding +// to a project pin. Every nonblank version is explicit; known releases select +// their own lock entry in either mode, and other tags render unpinned. func hasuraImage(version string) string { version = strings.TrimSpace(version) entry := HasuraEntry() v14, _ := LockedRef("hasura-v14") cur, _ := LockedRef("hasura") switch { - case v14.IsDefaultVersion(version): - version = "" - case cur.IsDefaultVersion(version): - entry, version = "hasura", "" + case version != "" && v14.IsDefaultVersion(version): + entry = "hasura-v14" + case version != "" && cur.IsDefaultVersion(version): + entry = "hasura" } if entry == "hasura" { return ResolveImage("hasura", ImageRef("hasura", version)) diff --git a/internal/compose/hasura_image_test.go b/internal/compose/hasura_image_test.go index 3ea891cb1..0b1577826 100644 --- a/internal/compose/hasura_image_test.go +++ b/internal/compose/hasura_image_test.go @@ -49,9 +49,9 @@ func TestHasuraImageByMode(t *testing.T) { {"v1.4 lock default", "v2.44.0", false, PinningLock, v14.String()}, {"v1.4 lock blank version", "", false, PinningLock, v14.String()}, {"v1.4 legacy default", "v2.44.0", false, PinningLegacy, "hasura/graphql-engine:v2.44.0"}, - {"v1.5 lock default", "v2.44.0", true, PinningLock, cur.String()}, // the config default nself init writes + {"v1.5 explicit v1.4 release", "v2.44.0", true, PinningLock, v14.String()}, {"v1.5 lock blank version", "", true, PinningLock, cur.String()}, - {"v1.5 legacy default", "v2.44.0", true, PinningLegacy, "hasura/graphql-engine:v2.51.0"}, + {"v1.5 explicit v1.4 legacy release", "v2.44.0", true, PinningLegacy, "hasura/graphql-engine:v2.44.0"}, {"explicit current release in v1.4", "v2.51.0", false, PinningLock, cur.String()}, {"explicit current release in v1.4 legacy", "v2.51.0", false, PinningLegacy, "hasura/graphql-engine:v2.51.0"}, {"explicit older release in v1.4", "v2.43.0", false, PinningLock, "hasura/graphql-engine:v2.43.0"}, @@ -89,8 +89,7 @@ func TestHasuraLockPinKept(t *testing.T) { var notices bytes.Buffer defer PinNoticeSink(¬ices)() defer SetProjectPins(map[string]string{"hasura": pin})() - // What nself init writes into .env (v2.44.0) and a blank value are CLI defaults and - // not overrides: the pin stays. + // Blank keeps the pin; an explicit version matching the pin keeps its digest. for _, v := range []string{"", v14.Version} { t.Setenv("HASURA_VERSION", v) if got := hasuraImageFor(t, v); got != pin { @@ -100,8 +99,7 @@ func TestHasuraLockPinKept(t *testing.T) { if notices.Len() != 0 { t.Errorf("a kept pin printed a notice: %q", notices.String()) } - // Any other version is explicit and wins over the pin, with one notice: the - // current release (init never writes it) and a tag the lock does not know. + // A different explicit version wins over the pin with one notice. for v, want := range map[string]string{cur.Version: cur.String(), "v2.45.0": "hasura/graphql-engine:v2.45.0"} { notices.Reset() resetPinNotices(t) @@ -118,18 +116,18 @@ func TestHasuraLockPinKept(t *testing.T) { setMode(t, PinningLock) defer SetProjectPins(nil)() compattest.Set(t, false) - if got := hasuraImageFor(t, "v2.44.0"); got != v14.String() { + if got := hasuraImageFor(t, ""); got != v14.String() { t.Errorf("v1.4 without a pin: %q, want %q", got, v14.String()) } compattest.Set(t, true) - if got := hasuraImageFor(t, "v2.44.0"); got != cur.String() { + if got := hasuraImageFor(t, ""); got != cur.String() { t.Errorf("v1.5 without a pin: %q, want %q", got, cur.String()) } }) } // TestHasuraLockPinKeptAfterUpdate: a project `nself update` moved to v2.51.0 keeps -// that pin whether .env still holds the init default or names the release. +// that pin whether .env leaves the version blank or names the same release. func TestHasuraLockPinKeptAfterUpdate(t *testing.T) { cur, _ := LockedRef("hasura") compattest.Both(t, func(t *testing.T) { @@ -137,7 +135,7 @@ func TestHasuraLockPinKeptAfterUpdate(t *testing.T) { var notices bytes.Buffer defer PinNoticeSink(¬ices)() defer SetProjectPins(map[string]string{"hasura": cur.String()})() - for _, v := range []string{"", "v2.44.0", cur.Version} { + for _, v := range []string{"", cur.Version} { t.Setenv("HASURA_VERSION", v) if got := hasuraImageFor(t, v); got != cur.String() { t.Errorf("HASURA_VERSION=%q: hasura image = %q, want the pin %q", v, got, cur.String()) diff --git a/internal/compose/image_inputs.go b/internal/compose/image_inputs.go index 165cb4ca8..d862ff81c 100644 --- a/internal/compose/image_inputs.go +++ b/internal/compose/image_inputs.go @@ -30,8 +30,7 @@ type ImageInput struct { Vars []string // variables that feed the rendered reference, precedence order alsoDefaultEntries []string // explicit compatibility defaults that render this entry // NotInitDefault marks an entry whose version nself init never writes into .env - // (hasura: init writes v2.44.0, the hasura-v14 entry). Setting a variable to that - // version is a deliberate choice, so it is an override and not a default. + // (Hasura leaves the version blank). Any nonblank value is an override. NotInitDefault bool } @@ -41,12 +40,9 @@ var imageInputs = []ImageInput{ // pgvector repository from the major version. {Service: "postgres", Entry: "postgres", Vars: []string{"POSTGRES_IMAGE", "POSTGRES_VERSION"}}, {Service: "postgres", Entry: "pgvector", Vars: []string{"POSTGRES_IMAGE", "POSTGRES_VERSION"}, alsoDefaultEntries: []string{"postgres"}}, - // hasura: the current release (v2.51.0). init does not write it, so a project that - // sets HASURA_VERSION to it moves off a lock pin of v2.44.0 (P7-LIVE-23). - {Service: "hasura", Entry: "hasura", Vars: []string{"HASURA_VERSION"}, alsoDefaultEntries: []string{"hasura-v14"}, NotInitDefault: true}, - // hasura-v14: graphql-engine v2.44.0, what v1.4 runs and the version nself init - // writes into .env (core_services.go, P7-LIVE-23); the same variable picks the tag. - {Service: "hasura", Entry: "hasura-v14", Vars: []string{"HASURA_VERSION"}}, + // Hasura: init leaves the version blank; both releases are explicit choices. + {Service: "hasura", Entry: "hasura", Vars: []string{"HASURA_VERSION"}, NotInitDefault: true}, + {Service: "hasura", Entry: "hasura-v14", Vars: []string{"HASURA_VERSION"}, NotInitDefault: true}, {Service: "auth", Entry: "auth", Vars: []string{"AUTH_VERSION"}, alsoDefaultEntries: []string{"auth-v14"}}, // auth-v14: nhost/auth 0.36.0, what v1.4 runs and v1.5 until AUTH_ENCRYPTION_KEY is // set (core_services_auth_nginx.go, P7-LIVE-20); the same variable picks the tag. diff --git a/internal/compose/project_pins_test.go b/internal/compose/project_pins_test.go index b429bcbab..7efedbe8f 100644 --- a/internal/compose/project_pins_test.go +++ b/internal/compose/project_pins_test.go @@ -134,8 +134,9 @@ func TestImageInputDefaultCompatibilityIsDirectional(t *testing.T) { {"postgres rejects pgvector default", "postgres", pgvector.Version, false}, {"valkey accepts redis init default", "valkey", redis.Version, true}, {"redis rejects valkey default", "redis", valkey.Version, false}, - {"current hasura accepts init default", "hasura", v14.Version, true}, + {"current hasura treats v1.4 as explicit", "hasura", v14.Version, false}, {"current hasura remains explicit", "hasura", current.Version, false}, + {"v1.4 hasura remains explicit", "hasura-v14", v14.Version, false}, } { t.Run(tc.name, func(t *testing.T) { if got := rows[tc.entry].isDefault(tc.value); got != tc.want { diff --git a/internal/config/defaults_postgres_hasura.go b/internal/config/defaults_postgres_hasura.go index 71715026d..215add494 100644 --- a/internal/config/defaults_postgres_hasura.go +++ b/internal/config/defaults_postgres_hasura.go @@ -75,10 +75,7 @@ func applyDefaultsHasura(cfg *Config) error { // applyDefaultsHasuraCreds generates Hasura admin secret and JWT key when missing. func applyDefaultsHasuraCreds(cfg *Config) error { - if cfg.Hasura.Version == "" { - cfg.Hasura.Version = "v2.44.0" - slog.Debug("default", "key", "HASURA_VERSION", "value", cfg.Hasura.Version) - } + // Preserve blank: compose selects the mode default or an existing lock pin. if cfg.Hasura.AdminSecret == "" { // Generated for the first build only: build persists it to .env.secrets // (internal/build/orchestrator_secrets.go) so later loads read it back. diff --git a/internal/config/exports.go b/internal/config/exports.go index 8759d21d7..3818121b9 100644 --- a/internal/config/exports.go +++ b/internal/config/exports.go @@ -30,7 +30,7 @@ func DefaultFor(key string) string { "POSTGRES_EXTENSIONS": "uuid-ossp", // Hasura - "HASURA_VERSION": "v2.44.0", + "HASURA_VERSION": "", "HASURA_JWT_TYPE": "HS256", // JWT-ALGO-01: HS256 default — matches auto-generated JWTKey; RS256 requires user-supplied key material "HASURA_ROUTE": "api", "HASURA_PORT": "8080", diff --git a/internal/setup/setup_env_files.go b/internal/setup/setup_env_files.go index e44644041..47b288e45 100644 --- a/internal/setup/setup_env_files.go +++ b/internal/setup/setup_env_files.go @@ -95,7 +95,8 @@ PGVECTOR_HNSW_M=16 PGVECTOR_HNSW_EF_CONSTRUCTION=64 # ── Hasura GraphQL ──────────────────────────────────────── -HASURA_VERSION=v2.44.0 +# Blank selects the mode default or the existing project lock pin. +HASURA_VERSION= HASURA_GRAPHQL_ADMIN_SECRET=%s HASURA_JWT_KEY=%s HASURA_JWT_TYPE=HS256 @@ -165,7 +166,8 @@ POSTGRES_USER=postgres POSTGRES_PASSWORD=%s # ── Hasura GraphQL ──────────────────────────────────────── -HASURA_VERSION=v2.44.0 +# Blank selects the mode default or the existing project lock pin. +HASURA_VERSION= HASURA_GRAPHQL_ADMIN_SECRET=%s HASURA_JWT_KEY=%s HASURA_JWT_TYPE=HS256 From 5e083549d9ea7a4bd7a00ca34abffbed4f294cd9 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 28 Oct 2026 16:39:28 -0400 Subject: [PATCH 03/13] fix(compose): preserve custom service MinIO identity (D-0639) --- internal/build/secrets_template.go | 7 +++++- internal/build/secrets_template_test.go | 29 +++++++++++++++++++++++++ internal/compose/custom_service_test.go | 4 ++-- internal/compose/custom_services.go | 2 +- internal/compose/object_storage.go | 2 +- internal/compose/object_storage_test.go | 5 +++++ 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/build/secrets_template.go b/internal/build/secrets_template.go index ce47e3c3d..4e8709329 100644 --- a/internal/build/secrets_template.go +++ b/internal/build/secrets_template.go @@ -118,7 +118,12 @@ func TemplateSecrets(composeYAML []byte, secrets map[string]string) []byte { } sort.Strings(keys) for _, key := range keys { - out = rewriteKeyLines(out, key, func(string) (string, bool) { + out = rewriteKeyLines(out, key, func(line string) (string, bool) { + // The generator may deliberately select a different credential source + // (CS_N S3 keys use MinIO root credentials). Preserve explicit refs. + if ref := composeRefPattern.FindString(line); ref != "" && lineValueIs(line, ref) { + return "", false + } return "${" + key + "}", true }) } diff --git a/internal/build/secrets_template_test.go b/internal/build/secrets_template_test.go index d548d11b1..b6ef891a2 100644 --- a/internal/build/secrets_template_test.go +++ b/internal/build/secrets_template_test.go @@ -542,3 +542,32 @@ func TestRedisCLIURLGuardParsesYAMLLists(t *testing.T) { t.Fatalf("a YAML parse error must be reported, got %v", got) } } + +// Custom services keep the MinIO root identity selected by the generator even +// when a distinct application S3 credential pair is configured (D-0639). +func TestTemplateSecretsCustomServiceMinioIdentity(t *testing.T) { + compattest.Set(t, false) + cfg := secretTestConfig() + cfg.Minio.S3AccessKey = "application-access" + cfg.Minio.S3SecretKey = "application-secret" + cfg.CustomServices = []config.CustomService{{Name: "worker", Port: 3000, Template: "express-js"}} + raw, err := compose.NewGenerator(cfg).Generate() + if err != nil { + t.Fatal(err) + } + templated := TemplateSecrets(raw, SecretEnvMap(cfg)) + var doc struct { + Services map[string]struct { + Environment map[string]string `yaml:"environment"` + } `yaml:"services"` + } + if err := yaml.Unmarshal(templated, &doc); err != nil { + t.Fatal(err) + } + env := doc.Services["worker"].Environment + for key, want := range map[string]string{"S3_ACCESS_KEY": "${MINIO_ROOT_USER}", "S3_SECRET_KEY": "${MINIO_ROOT_PASSWORD}"} { + if env[key] != want { + t.Errorf("%s = %q, want %q", key, env[key], want) + } + } +} diff --git a/internal/compose/custom_service_test.go b/internal/compose/custom_service_test.go index 9c4e93bcd..11631870d 100644 --- a/internal/compose/custom_service_test.go +++ b/internal/compose/custom_service_test.go @@ -227,8 +227,8 @@ func TestCoreEnvVars_MinioPresent(t *testing.T) { if !strings.Contains(env["S3_ENDPOINT"], "minio:9000") { t.Errorf("S3_ENDPOINT should reference minio:9000, got %q", env["S3_ENDPOINT"]) } - if env["S3_ACCESS_KEY"] != "minioadmin" { - t.Errorf("S3_ACCESS_KEY = %q, want %q", env["S3_ACCESS_KEY"], "minioadmin") + if env["S3_ACCESS_KEY"] != "${MINIO_ROOT_USER}" { + t.Errorf("S3_ACCESS_KEY = %q, want %q", env["S3_ACCESS_KEY"], "${MINIO_ROOT_USER}") } // The MinIO root password reaches the container through compose.env, not as // a literal in the compose file (D-0334). diff --git a/internal/compose/custom_services.go b/internal/compose/custom_services.go index c8ff0218c..42b93f926 100644 --- a/internal/compose/custom_services.go +++ b/internal/compose/custom_services.go @@ -92,7 +92,7 @@ func addOptionalStoreEnvVars(env map[string]string, cfg *config.Config) { } if cfg.Minio.Enabled { env["S3_ENDPOINT"] = fmt.Sprintf("http://minio:%d", cfg.Minio.Port) - env["S3_ACCESS_KEY"] = cfg.Minio.RootUser + env["S3_ACCESS_KEY"] = secretRef("MINIO_ROOT_USER", cfg.Minio.RootUser) env["S3_SECRET_KEY"] = secretRef("MINIO_ROOT_PASSWORD", cfg.Minio.RootPassword) env["S3_BUCKET"] = cfg.Minio.DefaultBuckets } diff --git a/internal/compose/object_storage.go b/internal/compose/object_storage.go index 576c07205..0ca737018 100644 --- a/internal/compose/object_storage.go +++ b/internal/compose/object_storage.go @@ -254,7 +254,7 @@ func addActiveStoreEnvVars(env map[string]string, cfg *config.Config) { setBucketEnv(env, os.Getenv("OBJECT_STORAGE_BUCKETS")) case "minio-plugin": env["S3_ENDPOINT"] = "http://minio:9000" - env["S3_ACCESS_KEY"] = cfg.Minio.RootUser + env["S3_ACCESS_KEY"] = secretRef("MINIO_ROOT_USER", cfg.Minio.RootUser) env["S3_SECRET_KEY"] = secretRef("MINIO_ROOT_PASSWORD", cfg.Minio.RootPassword) setBucketEnv(env, cfg.Minio.DefaultBuckets) } diff --git a/internal/compose/object_storage_test.go b/internal/compose/object_storage_test.go index 4985bfa79..206680b87 100644 --- a/internal/compose/object_storage_test.go +++ b/internal/compose/object_storage_test.go @@ -333,6 +333,11 @@ func TestS3EndpointCustomServices(t *testing.T) { if env["S3_ENDPOINT"] != tc.endpoint { t.Fatalf("S3_ENDPOINT=%q want %q", env["S3_ENDPOINT"], tc.endpoint) } + if tc.endpoint == "http://minio:9000" { + if env["S3_ACCESS_KEY"] != "${MINIO_ROOT_USER}" || env["S3_SECRET_KEY"] != "${MINIO_ROOT_PASSWORD}" { + t.Fatalf("MinIO identity lost: %v", env) + } + } if tc.endpoint == "" { for _, key := range []string{"S3_ACCESS_KEY", "S3_SECRET_KEY", "S3_BUCKET"} { if _, ok := env[key]; ok { From 3d99dfb79fc399a7b81eda29c1f76bd98de559ed Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 28 Oct 2026 16:40:59 -0400 Subject: [PATCH 04/13] test(logs): pin legacy stream bytes and record schema path (D-0750) --- cmd/commands/json_io_test.go | 21 +++++++-------------- tools/schemagen/spec_test.go | 16 ++++++++++++++++ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/cmd/commands/json_io_test.go b/cmd/commands/json_io_test.go index 8628d011f..1a0367f7f 100644 --- a/cmd/commands/json_io_test.go +++ b/cmd/commands/json_io_test.go @@ -466,20 +466,13 @@ func TestLogsJSONFollowLegacyBytes(t *testing.T) { if err != nil { t.Fatal(err) } - want := "" - for _, line := range ioLogLines { - want += `{"message":` + strconv.Quote(line) + "}\n" - } - // Legacy level detection runs on the entire prefixed line. - want = strings.Replace(want, `{"message":"web-1 | 2026-01-02T03:04:05Z`, `{"level":"info","message":"web-1 | 2026-01-02T03:04:05Z`, 1) - want = strings.Replace(want, `{"message":"db-1 | 2026-01-02T03:04:07Z`, `{"level":"error","message":"db-1 | 2026-01-02T03:04:07Z`, 1) - want = strings.Replace(want, `{"message":"db-1 | 2026-01-02T03:04:08Z`, `{"level":"warn","message":"db-1 | 2026-01-02T03:04:08Z`, 1) - want = strings.ReplaceAll(want, `started"}`, `started","service":"web-1 |"}`) - want = strings.ReplaceAll(want, `200"}`, `200","service":"web-1 |"}`) - want = strings.ReplaceAll(want, `refused"}`, `refused","service":"db-1 |"}`) - want = strings.ReplaceAll(want, `done"}`, `done","service":"db-1 |"}`) - want = strings.ReplaceAll(want, `timestamp"}`, `timestamp","service":"web-1 |"}`) - want = strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(want, "<", `\u003c`), ">", `\u003e`), "&", `\u0026`) + want := strings.Join([]string{ + `{"level":"info","message":"web-1 | 2026-01-02T03:04:05Z [info] server started","service":"web-1 |"}`, + `{"message":"web-1 | 2026-01-02T03:04:06Z GET /health 200","service":"web-1 |"}`, + `{"level":"error","message":"db-1 | 2026-01-02T03:04:07Z [error] connection refused","service":"db-1 |"}`, + `{"level":"warn","message":"db-1 | 2026-01-02T03:04:08Z [warn] slow query \u003cb\u003e \u0026 done","service":"db-1 |"}`, + `{"message":"web-1 | plain line without a timestamp","service":"web-1 |"}`, + }, "\n") + "\n" if out != want { t.Fatalf("legacy follow bytes changed: got %s want %s", out, want) } diff --git a/tools/schemagen/spec_test.go b/tools/schemagen/spec_test.go index b3e732920..851033c18 100644 --- a/tools/schemagen/spec_test.go +++ b/tools/schemagen/spec_test.go @@ -181,3 +181,19 @@ func TestCommittedSchemasAreCurrent(t *testing.T) { t.Fatalf("schemas are stale (run `make schemas`): exit %d\n%s", code, msg) } } + +func TestStreamRecordOut(t *testing.T) { + if got := recordOut("logs"); got != "commands/logs.record.v1.schema.json" { + t.Fatalf("record path = %q", got) + } + ss := newSpecSet() + ss.register(scratchSpec(recordOut("logs"))) + if problems := ss.problemList(); len(problems) != 0 { + t.Fatalf("record schema rejected: %v", problems) + } + for _, path := range []string{"../logs.v1.schema.json", "commands/logs..record.v1.schema.json"} { + if outPattern.MatchString(path) { + t.Errorf("unsafe path accepted: %q", path) + } + } +} From a4b71c1610ba21520beb557c799e559dbcb953c5 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 28 Oct 2026 16:42:18 -0400 Subject: [PATCH 05/13] test(compose): refresh custom service credential reference fixture (D-0639) --- internal/compose/testdata/object_storage/v14-custom.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/compose/testdata/object_storage/v14-custom.yaml b/internal/compose/testdata/object_storage/v14-custom.yaml index 6cc3e8ee0..b82015d3e 100644 --- a/internal/compose/testdata/object_storage/v14-custom.yaml +++ b/internal/compose/testdata/object_storage/v14-custom.yaml @@ -271,7 +271,7 @@ services: POSTGRES_PORT: "5432" POSTGRES_USER: postgres PROJECT_NAME: storage-fixture - S3_ACCESS_KEY: test-access-key + S3_ACCESS_KEY: ${MINIO_ROOT_USER} S3_BUCKET: "" S3_ENDPOINT: http://minio:0 S3_SECRET_KEY: ${MINIO_ROOT_PASSWORD} From 83cab24c47a88ad145dec4c12e4064059bc3a057 Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 28 Oct 2026 16:43:41 -0400 Subject: [PATCH 06/13] test(logs): refresh machine tool schemas (D-0750) --- internal/mcpgen/testdata/tools.golden.json | 25 ---------------------- 1 file changed, 25 deletions(-) diff --git a/internal/mcpgen/testdata/tools.golden.json b/internal/mcpgen/testdata/tools.golden.json index 80cfc6ecd..f0994e146 100644 --- a/internal/mcpgen/testdata/tools.golden.json +++ b/internal/mcpgen/testdata/tools.golden.json @@ -21486,11 +21486,6 @@ "flags": { "additionalProperties": false, "properties": { - "ai": { - "default": false, - "description": "Run the AI first-run wizard (install Ollama, setup Gemini pool, verify)", - "type": "boolean" - }, "alerts": { "default": false, "description": "Check monitoring alert rules are loaded", @@ -21531,11 +21526,6 @@ "description": "Print OAuth URL instead of opening browser (for SSH/headless servers)", "type": "boolean" }, - "install-check": { - "default": false, - "description": "Run 6-stage onboarding funnel check (used by Homebrew post-install hook)", - "type": "boolean" - }, "only": { "default": "", "description": "Run only a specific subsystem check (host, docker, postgres, hasura, nginx, ssl, ping, plugins, license, monitoring, backups, security)", @@ -25396,26 +25386,11 @@ "description": "Show logs since (e.g. 1h, 30m, or RFC3339)", "type": "string" }, - "status": { - "default": false, - "description": "Show service status overview", - "type": "boolean" - }, - "summary": { - "default": false, - "description": "Show recent errors by service", - "type": "boolean" - }, "tail": { "default": 10, "description": "Number of lines to show", "type": "integer" }, - "top": { - "default": false, - "description": "Show most active services", - "type": "boolean" - }, "until": { "default": "", "description": "Show logs until (e.g. 1h or RFC3339)", From 34da13f11ab80505007087c023ee3a9c83819596 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 17:41:02 -0400 Subject: [PATCH 07/13] fix(compose): keep hasura v2.51.0 pin when .env carries stale v2.44.0 init default Projects upgraded by nself update that still have HASURA_VERSION=v2.44.0 in .env were downgraded to hasura-v14 because the old init default matched hasura-v14.IsDefaultVersion and switched the entry away from the v2.51.0 pin. Treat v2.44.0 as non-explicit when the project pin is hasura (v2.51.0) so the newer pin wins without requiring users to clear stale .env values. Also fix the composePrefixRE doc comment placement in logs_json.go and document the migration in Image-Lock.md and Config-Hasura.md. --- .github/wiki/Config-Hasura.md | 2 +- .github/wiki/Image-Lock.md | 4 ++-- cmd/commands/logs_json.go | 4 +++- internal/compose/core_services.go | 30 ++++++++++++++++++++++++-- internal/compose/hasura_image_test.go | 31 +++++++++++++++++++++++++-- 5 files changed, 63 insertions(+), 8 deletions(-) diff --git a/.github/wiki/Config-Hasura.md b/.github/wiki/Config-Hasura.md index 94ed19d32..697f5f6ec 100644 --- a/.github/wiki/Config-Hasura.md +++ b/.github/wiki/Config-Hasura.md @@ -10,7 +10,7 @@ All Hasura configuration is driven by `HASURA_*` variables in your `.env` files. | Variable | Type | Default | Required | Description | |----------|------|---------|----------|-------------| -| `HASURA_VERSION` | string | blank | No | Blank keeps the project lock pin, or uses v2.44.0 in v1.4 and v2.51.0 in v1.5. Any nonblank tag is explicit. | +| `HASURA_VERSION` | string | blank | No | Blank keeps the project lock pin, or uses v2.44.0 in v1.4 and v2.51.0 in v1.5. Any nonblank tag is explicit, except `v2.44.0` is treated as the old init default (non-explicit) when the project lock pins v2.51.0, so upgraded projects keep their newer pin without clearing `.env`. | | `HASURA_GRAPHQL_ADMIN_SECRET` | string | *(none)* | **Yes** | Admin API secret (minimum 32 characters). Required to access the Hasura Console and the admin API endpoint. | | `HASURA_JWT_KEY` | string | *(none)* | **Yes** | JWT signing key (minimum 32 characters). Used to verify tokens issued by the Auth service. | | `HASURA_JWT_TYPE` | string | `HS256` | No | JWT algorithm. Supported values: `HS256`, `RS256`, and other algorithms supported by Hasura. | diff --git a/.github/wiki/Image-Lock.md b/.github/wiki/Image-Lock.md index 17f8dd29d..824772ba1 100644 --- a/.github/wiki/Image-Lock.md +++ b/.github/wiki/Image-Lock.md @@ -102,11 +102,11 @@ The lock carries two Hasura entries on `docker.io/hasura/graphql-engine`: `hasur | v1.5, no lock pin yet (a new project) | `hasura` (v2.51.0) | | the project lock pins v2.44.0 | the pin, in both modes, until `nself update` moves it | | `HASURA_VERSION=` (what `nself init` writes into `.env`) or unset | the mode default, or the pin if there is one | -| `HASURA_VERSION=v2.44.0` | `hasura-v14` in any mode; explicit, so a different project pin yields with one notice | +| `HASURA_VERSION=v2.44.0` | `hasura-v14` when there is no newer pin; when the project pin is `hasura` (v2.51.0), this old init default is treated as non-explicit and the v2.51.0 pin wins (migration for projects upgraded by `nself update` or `nself init`) | | `HASURA_VERSION=v2.51.0` | `hasura` in any mode; it is an override, so it drops a lock pin of v2.44.0 with one notice | | any other `HASURA_VERSION` (`v2.45.0`) | `hasura/graphql-engine:`, unpinned, in any mode | -Existing env files containing `HASURA_VERSION=v2.44.0` now explicitly select that release. Clear the value to follow the project's lock pin or mode default. `nself service upgrade hasura v2.44.0` writes an explicit selection and takes effect on the next build. +Existing env files containing `HASURA_VERSION=v2.44.0` from older `nself init` runs are treated as the old init default, not as an explicit pin, when the project lock pins v2.51.0; the newer pin wins without requiring users to clear `.env`. Projects without a v2.51.0 pin still resolve `v2.44.0` to `hasura-v14`. To force v2.44.0 on a v2.51.0-pinned project, use `nself service upgrade hasura v2.44.0` (writes an explicit selection) or set `IMAGE_PINNING=legacy`. Hasura v2.51.0 does not change the metadata format or the migration contract. `scripts/ci/hasura-upgrade-smoke.sh` starts a project on v2.44.0 with a migrated fixture (tables and rows) and metadata (tracked tables, a relationship, a select permission, an event trigger to a webhook, a remote schema), takes a verified backup, switches the pin to v2.51.0 and checks that the rows are readable, that the metadata export is identical before and after (the `nself db hasura metadata export` tree and the normalised API JSON), that apply then export round-trips, and that a mutation, a subscription event, a remote schema call and an event trigger delivery work. The metadata catalog version is 48 on both releases. diff --git a/cmd/commands/logs_json.go b/cmd/commands/logs_json.go index b7bed9d5d..0b7cc41c7 100644 --- a/cmd/commands/logs_json.go +++ b/cmd/commands/logs_json.go @@ -21,9 +21,11 @@ import ( "github.com/spf13/cobra" ) -// composePrefixRE matches the " | " prefix docker compose puts on every line. +// recordTimestampPrefixRE matches a leading ISO-8601 timestamp that some log +// drivers prepend before the compose service prefix. var recordTimestampPrefixRE = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})?[ \t]?`) +// composePrefixRE matches the " | " prefix docker compose puts on every line. var composePrefixRE = regexp.MustCompile(`^([^\s|]+)\s*\|\s?`) // logRecordFromLine splits one raw compose log line into its record: service diff --git a/internal/compose/core_services.go b/internal/compose/core_services.go index 9b868323e..dea1e71e4 100644 --- a/internal/compose/core_services.go +++ b/internal/compose/core_services.go @@ -2,6 +2,7 @@ package compose import ( "fmt" + "os" "sort" "strings" @@ -217,13 +218,38 @@ func HasuraEntry() string { } // hasuraImage renders HASURA_VERSION. Blank selects the mode default, yielding -// to a project pin. Every nonblank version is explicit; known releases select -// their own lock entry in either mode, and other tags render unpinned. +// to a project pin. Known releases select their own lock entry in either mode, +// and other tags render unpinned. Migration: when the project pin is hasura +// (v2.51.0) and HASURA_VERSION holds the old init default v2.44.0 (hasura-v14's +// legacy_ref), that value is treated as non-explicit so the newer pin wins; +// projects upgraded by `nself update` keep their v2.51.0 pin without requiring +// users to clear stale .env values written by older `nself init` runs. func hasuraImage(version string) string { version = strings.TrimSpace(version) entry := HasuraEntry() v14, _ := LockedRef("hasura-v14") cur, _ := LockedRef("hasura") + // Migration: a project pinned to hasura (v2.51.0) keeps that pin when + // HASURA_VERSION still carries the old init default v2.44.0. Without this, + // v14.IsDefaultVersion would switch entry to hasura-v14 and downgrade, and + // projectPin would see the env var as explicit and drop the pin with a notice. + pin, hasPin := ProjectPins()["hasura"] + staleInitDefault := hasPin && pin == cur.String() && version != "" && v14.IsDefaultVersion(version) && !cur.IsDefaultVersion(version) + if staleInitDefault { + // Treat the stale init default as blank: the pin wins, no notice. + version = "" + // Temporarily clear the env var so projectPin does not see it as + // explicit and drop the pin with a notice. Restore after resolve. + prev, wasSet := os.LookupEnv("HASURA_VERSION") + os.Unsetenv("HASURA_VERSION") + defer func() { + if wasSet { + os.Setenv("HASURA_VERSION", prev) + } else { + os.Unsetenv("HASURA_VERSION") + } + }() + } switch { case version != "" && v14.IsDefaultVersion(version): entry = "hasura-v14" diff --git a/internal/compose/hasura_image_test.go b/internal/compose/hasura_image_test.go index 0b1577826..b5e1d8eeb 100644 --- a/internal/compose/hasura_image_test.go +++ b/internal/compose/hasura_image_test.go @@ -127,15 +127,22 @@ func TestHasuraLockPinKept(t *testing.T) { } // TestHasuraLockPinKeptAfterUpdate: a project `nself update` moved to v2.51.0 keeps -// that pin whether .env leaves the version blank or names the same release. +// that pin whether .env leaves the version blank, names the same release, or +// still carries the old init default v2.44.0 (migration: stale init defaults +// do not downgrade an upgraded project). func TestHasuraLockPinKeptAfterUpdate(t *testing.T) { cur, _ := LockedRef("hasura") + v14, _ := LockedRef("hasura-v14") compattest.Both(t, func(t *testing.T) { setMode(t, PinningLock) var notices bytes.Buffer defer PinNoticeSink(¬ices)() defer SetProjectPins(map[string]string{"hasura": cur.String()})() - for _, v := range []string{"", cur.Version} { + // Blank, the pin's own version, and the old init default v2.44.0 all + // keep the v2.51.0 pin. The last case is the migration: projects whose + // .env still says HASURA_VERSION=v2.44.0 from an older `nself init` + // must not be downgraded after `nself update` moves the pin to v2.51.0. + for _, v := range []string{"", cur.Version, v14.Version} { t.Setenv("HASURA_VERSION", v) if got := hasuraImageFor(t, v); got != cur.String() { t.Errorf("HASURA_VERSION=%q: hasura image = %q, want the pin %q", v, got, cur.String()) @@ -146,3 +153,23 @@ func TestHasuraLockPinKeptAfterUpdate(t *testing.T) { } }) } + +// TestHasuraInitUpgradeWithoutPin: without a project pin, HASURA_VERSION=v2.44.0 +// remains explicit even in v1.5 — the migration only applies when a v2.51.0 pin +// exists (nself update case). This test documents that boundary. +func TestHasuraInitUpgradeKeepsNewerPin(t *testing.T) { + v14, _ := LockedRef("hasura-v14") + setMode(t, PinningLock) + defer SetProjectPins(nil)() +compattest.Set(t, true) +t.Setenv("HASURA_VERSION", v14.Version) + // Without a pin, v2.44.0 is explicit and resolves to hasura-v14. + if got := hasuraImageFor(t, v14.Version); got != v14.String() { + t.Errorf("v1.5 without pin, HASURA_VERSION=%s: hasura image = %q, want explicit v14 %q", v14.Version, got, v14.String()) +} + // v1.4 also treats v2.44.0 as explicit (it matches hasura-v14's default). +compattest.Set(t, false) +if got := hasuraImageFor(t, v14.Version); got != v14.String() { + t.Errorf("v1.4 without pin, HASURA_VERSION=%s: hasura image = %q, want explicit v14 %q", v14.Version, got, v14.String()) +} +} From 22fa164f21f8cd124a3eeeaff01e8e70e9b38802 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Wed, 28 Oct 2026 18:19:21 -0400 Subject: [PATCH 08/13] fix(compose): make explicit HASURA_VERSION override project pins without env mutation Remove the staleInitDefault migration branch that treated v2.44.0 as non-explicit when a v2.51.0 pin existed. Every nonblank HASURA_VERSION is now explicit and overrides a project pin, including v2.44.0 written by service upgrade hasura v2.44.0. Pass the version to projectPin explicitly via projectPinWithVersion instead of mutating the process environment with os.Unsetenv/os.Setenv. Update docs and tests to reflect the new behavior. --- .github/wiki/Config-Hasura.md | 2 +- .github/wiki/Image-Lock.md | 4 +- internal/compose/core_services.go | 55 +++++++++---------- internal/compose/hasura_image_test.go | 23 +++++--- .../compose/image_inputs_callsites_test.go | 2 +- internal/compose/project_pins.go | 24 +++++++- 6 files changed, 68 insertions(+), 42 deletions(-) diff --git a/.github/wiki/Config-Hasura.md b/.github/wiki/Config-Hasura.md index 697f5f6ec..ebc0a79a5 100644 --- a/.github/wiki/Config-Hasura.md +++ b/.github/wiki/Config-Hasura.md @@ -10,7 +10,7 @@ All Hasura configuration is driven by `HASURA_*` variables in your `.env` files. | Variable | Type | Default | Required | Description | |----------|------|---------|----------|-------------| -| `HASURA_VERSION` | string | blank | No | Blank keeps the project lock pin, or uses v2.44.0 in v1.4 and v2.51.0 in v1.5. Any nonblank tag is explicit, except `v2.44.0` is treated as the old init default (non-explicit) when the project lock pins v2.51.0, so upgraded projects keep their newer pin without clearing `.env`. | +| `HASURA_VERSION` | string | blank | No | Blank keeps the project lock pin, or uses v2.44.0 in v1.4 and v2.51.0 in v1.5. Every nonblank tag is explicit and overrides a project pin; use `nself service upgrade hasura ` or set this variable to move a pinned project. | | `HASURA_GRAPHQL_ADMIN_SECRET` | string | *(none)* | **Yes** | Admin API secret (minimum 32 characters). Required to access the Hasura Console and the admin API endpoint. | | `HASURA_JWT_KEY` | string | *(none)* | **Yes** | JWT signing key (minimum 32 characters). Used to verify tokens issued by the Auth service. | | `HASURA_JWT_TYPE` | string | `HS256` | No | JWT algorithm. Supported values: `HS256`, `RS256`, and other algorithms supported by Hasura. | diff --git a/.github/wiki/Image-Lock.md b/.github/wiki/Image-Lock.md index 824772ba1..0a0bd7858 100644 --- a/.github/wiki/Image-Lock.md +++ b/.github/wiki/Image-Lock.md @@ -102,11 +102,11 @@ The lock carries two Hasura entries on `docker.io/hasura/graphql-engine`: `hasur | v1.5, no lock pin yet (a new project) | `hasura` (v2.51.0) | | the project lock pins v2.44.0 | the pin, in both modes, until `nself update` moves it | | `HASURA_VERSION=` (what `nself init` writes into `.env`) or unset | the mode default, or the pin if there is one | -| `HASURA_VERSION=v2.44.0` | `hasura-v14` when there is no newer pin; when the project pin is `hasura` (v2.51.0), this old init default is treated as non-explicit and the v2.51.0 pin wins (migration for projects upgraded by `nself update` or `nself init`) | +| `HASURA_VERSION=v2.44.0` | `hasura-v14`; explicit override that drops a v2.51.0 project pin with one notice | | `HASURA_VERSION=v2.51.0` | `hasura` in any mode; it is an override, so it drops a lock pin of v2.44.0 with one notice | | any other `HASURA_VERSION` (`v2.45.0`) | `hasura/graphql-engine:`, unpinned, in any mode | -Existing env files containing `HASURA_VERSION=v2.44.0` from older `nself init` runs are treated as the old init default, not as an explicit pin, when the project lock pins v2.51.0; the newer pin wins without requiring users to clear `.env`. Projects without a v2.51.0 pin still resolve `v2.44.0` to `hasura-v14`. To force v2.44.0 on a v2.51.0-pinned project, use `nself service upgrade hasura v2.44.0` (writes an explicit selection) or set `IMAGE_PINNING=legacy`. +Every nonblank `HASURA_VERSION` is explicit and overrides a project pin, including `v2.44.0`. Projects without a v2.51.0 pin resolve `v2.44.0` to `hasura-v14`. To move a v2.51.0-pinned project to v2.44.0, use `nself service upgrade hasura v2.44.0` or set `HASURA_VERSION=v2.44.0` in `.env`. Hasura v2.51.0 does not change the metadata format or the migration contract. `scripts/ci/hasura-upgrade-smoke.sh` starts a project on v2.44.0 with a migrated fixture (tables and rows) and metadata (tracked tables, a relationship, a select permission, an event trigger to a webhook, a remote schema), takes a verified backup, switches the pin to v2.51.0 and checks that the rows are readable, that the metadata export is identical before and after (the `nself db hasura metadata export` tree and the normalised API JSON), that apply then export round-trips, and that a mutation, a subscription event, a remote schema call and an event trigger delivery work. The metadata catalog version is 48 on both releases. diff --git a/internal/compose/core_services.go b/internal/compose/core_services.go index dea1e71e4..b69990341 100644 --- a/internal/compose/core_services.go +++ b/internal/compose/core_services.go @@ -2,7 +2,6 @@ package compose import ( "fmt" - "os" "sort" "strings" @@ -219,37 +218,14 @@ func HasuraEntry() string { // hasuraImage renders HASURA_VERSION. Blank selects the mode default, yielding // to a project pin. Known releases select their own lock entry in either mode, -// and other tags render unpinned. Migration: when the project pin is hasura -// (v2.51.0) and HASURA_VERSION holds the old init default v2.44.0 (hasura-v14's -// legacy_ref), that value is treated as non-explicit so the newer pin wins; -// projects upgraded by `nself update` keep their v2.51.0 pin without requiring -// users to clear stale .env values written by older `nself init` runs. +// and other tags render unpinned. Every nonblank HASURA_VERSION is explicit and +// overrides a project pin; use `nself service upgrade hasura ` or set +// HASURA_VERSION in .env to move a pinned project. func hasuraImage(version string) string { version = strings.TrimSpace(version) entry := HasuraEntry() v14, _ := LockedRef("hasura-v14") cur, _ := LockedRef("hasura") - // Migration: a project pinned to hasura (v2.51.0) keeps that pin when - // HASURA_VERSION still carries the old init default v2.44.0. Without this, - // v14.IsDefaultVersion would switch entry to hasura-v14 and downgrade, and - // projectPin would see the env var as explicit and drop the pin with a notice. - pin, hasPin := ProjectPins()["hasura"] - staleInitDefault := hasPin && pin == cur.String() && version != "" && v14.IsDefaultVersion(version) && !cur.IsDefaultVersion(version) - if staleInitDefault { - // Treat the stale init default as blank: the pin wins, no notice. - version = "" - // Temporarily clear the env var so projectPin does not see it as - // explicit and drop the pin with a notice. Restore after resolve. - prev, wasSet := os.LookupEnv("HASURA_VERSION") - os.Unsetenv("HASURA_VERSION") - defer func() { - if wasSet { - os.Setenv("HASURA_VERSION", prev) - } else { - os.Unsetenv("HASURA_VERSION") - } - }() - } switch { case version != "" && v14.IsDefaultVersion(version): entry = "hasura-v14" @@ -257,7 +233,28 @@ func hasuraImage(version string) string { entry = "hasura" } if entry == "hasura" { - return ResolveImage("hasura", ImageRef("hasura", version)) + return resolveHasuraImage("hasura", ImageRef("hasura", version), version) + } + return resolveHasuraImage("hasura", ImageRef("hasura-v14", version), version) +} + +// resolveHasuraImage resolves the hasura image with an explicit version override. +// Unlike ResolveImage, it passes the caller-supplied version to projectPin so an +// explicit HASURA_VERSION (including v2.44.0) overrides a project pin without +// mutating the process environment. +func resolveHasuraImage(service, image, version string) string { + if pin, ok := projectPinWithVersion(service, image, version); ok { + return pin + } + resolved := image + if resolved == "" { + resolved = DefaultImage(service) + } + if strings.Contains(resolved, "@sha256:") { + return resolved + } + if digest, ok := ImageDigests[service]; ok && digest != "" { + resolved = resolved + "@sha256:" + digest } - return ResolveImage("hasura", ImageRef("hasura-v14", version)) + return resolved } diff --git a/internal/compose/hasura_image_test.go b/internal/compose/hasura_image_test.go index b5e1d8eeb..0789cef80 100644 --- a/internal/compose/hasura_image_test.go +++ b/internal/compose/hasura_image_test.go @@ -127,9 +127,9 @@ func TestHasuraLockPinKept(t *testing.T) { } // TestHasuraLockPinKeptAfterUpdate: a project `nself update` moved to v2.51.0 keeps -// that pin whether .env leaves the version blank, names the same release, or -// still carries the old init default v2.44.0 (migration: stale init defaults -// do not downgrade an upgraded project). +// that pin when .env leaves the version blank or names the same release. +// Every nonblank HASURA_VERSION is explicit and overrides the pin, including +// v2.44.0 written by `nself service upgrade hasura v2.44.0`. func TestHasuraLockPinKeptAfterUpdate(t *testing.T) { cur, _ := LockedRef("hasura") v14, _ := LockedRef("hasura-v14") @@ -138,11 +138,8 @@ func TestHasuraLockPinKeptAfterUpdate(t *testing.T) { var notices bytes.Buffer defer PinNoticeSink(¬ices)() defer SetProjectPins(map[string]string{"hasura": cur.String()})() - // Blank, the pin's own version, and the old init default v2.44.0 all - // keep the v2.51.0 pin. The last case is the migration: projects whose - // .env still says HASURA_VERSION=v2.44.0 from an older `nself init` - // must not be downgraded after `nself update` moves the pin to v2.51.0. - for _, v := range []string{"", cur.Version, v14.Version} { + // Blank and the pin's own version keep the v2.51.0 pin silently. + for _, v := range []string{"", cur.Version} { t.Setenv("HASURA_VERSION", v) if got := hasuraImageFor(t, v); got != cur.String() { t.Errorf("HASURA_VERSION=%q: hasura image = %q, want the pin %q", v, got, cur.String()) @@ -151,6 +148,16 @@ func TestHasuraLockPinKeptAfterUpdate(t *testing.T) { if notices.Len() != 0 { t.Errorf("a kept pin printed a notice: %q", notices.String()) } + // v2.44.0 is explicit: it overrides the v2.51.0 pin with a notice. + notices.Reset() + t.Setenv("HASURA_VERSION", v14.Version) + want := v14.String() + if got := hasuraImageFor(t, v14.Version); got != want { + t.Errorf("HASURA_VERSION=%q: hasura image = %q, want %q", v14.Version, got, want) + } + if notices.Len() == 0 { + t.Error("expected a pin-drop notice for explicit v2.44.0 override") + } }) } diff --git a/internal/compose/image_inputs_callsites_test.go b/internal/compose/image_inputs_callsites_test.go index 0ec3d11d2..fc0e590d9 100644 --- a/internal/compose/image_inputs_callsites_test.go +++ b/internal/compose/image_inputs_callsites_test.go @@ -93,7 +93,7 @@ func scanCallSites(t *testing.T) (resolve, bare []callSite, problems []string) { } else if !lookupFiles[filepath.Base(path)] || !strings.Contains(filepath.ToSlash(path), "internal/compose/") { problems = append(problems, pos+": ImageRef with a non-literal entry name") } - case "ResolveImage": + case "ResolveImage", "resolveHasuraImage": if len(call.Args) < 2 { return true } diff --git a/internal/compose/project_pins.go b/internal/compose/project_pins.go index 8727df97d..033a9c0d6 100644 --- a/internal/compose/project_pins.go +++ b/internal/compose/project_pins.go @@ -138,6 +138,15 @@ func PinNoticeSink(w io.Writer) (restore func()) { // comparison is on the rendered reference, never on the raw env text. Dropping // a pin prints a one-line notice on stderr. func projectPin(service, image string) (string, bool) { + return projectPinWithVersion(service, image, "") +} + +// projectPinWithVersion is like projectPin but treats a nonblank version as an +// explicit override of the env variable for this service, without mutating the +// process environment. Hasura uses this so an explicit HASURA_VERSION (including +// v2.44.0 written by `nself service upgrade`) overrides a project pin; other +// services keep reading the env directly. +func projectPinWithVersion(service, image, version string) (string, bool) { pinsMu.RLock() pin, ok := projectPins[service] pinsMu.RUnlock() @@ -148,7 +157,20 @@ func projectPin(service, image string) (string, bool) { image = DefaultImage(service) } in := inputFor(service, image) - name := in.Set(os.Getenv) + var name string + if version != "" { + // Treat the caller-supplied version as if the env var were set to it. + // A non-default value is explicit and drops the pin with a notice; a + // default value keeps the pin silently. + if !in.isDefault(version) { + for _, v := range in.Vars { + name = v + break + } + } + } else { + name = in.Set(os.Getenv) + } switch { case repoOf(image) != repoOf(pin): if name == "" { From 6a5af8269ecfd52124b70372e1c91de1879a96af Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 06:21:19 -0400 Subject: [PATCH 09/13] fix(build): keep literal reference-shaped secrets templated (D-0639) --- internal/build/secrets_template.go | 4 ++-- internal/build/secrets_template_rewrite.go | 22 ++++++++++++++++++++++ internal/build/secrets_template_test.go | 20 ++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/internal/build/secrets_template.go b/internal/build/secrets_template.go index 4e8709329..8d5497dad 100644 --- a/internal/build/secrets_template.go +++ b/internal/build/secrets_template.go @@ -120,8 +120,8 @@ func TemplateSecrets(composeYAML []byte, secrets map[string]string) []byte { for _, key := range keys { out = rewriteKeyLines(out, key, func(line string) (string, bool) { // The generator may deliberately select a different credential source - // (CS_N S3 keys use MinIO root credentials). Preserve explicit refs. - if ref := composeRefPattern.FindString(line); ref != "" && lineValueIs(line, ref) { + // (CS_N S3 keys use MinIO root credentials). Preserve such refs. + if isCrossKeyRef(line, key, secrets) { return "", false } return "${" + key + "}", true diff --git a/internal/build/secrets_template_rewrite.go b/internal/build/secrets_template_rewrite.go index 4e47d43e6..c3c44251b 100644 --- a/internal/build/secrets_template_rewrite.go +++ b/internal/build/secrets_template_rewrite.go @@ -146,6 +146,28 @@ func replaceOutsideRefs(s, old, repl string) string { return sb.String() } +// composeRefName captures the variable name of a whole-value ${NAME} or +// ${NAME:-...} reference. +var composeRefName = regexp.MustCompile(`^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-.*)?\}$`) + +// isCrossKeyRef reports whether the "KEY: value" line holds a deliberate +// reference to a different mapped secret (CS_N S3_ACCESS_KEY: ${MINIO_ROOT_USER}). +// A value that merely looks like a reference, such as a literal SMTP password +// "${ABC}", names no mapped secret, or is the configured secret itself, and +// stays a literal so it is still rewritten to ${KEY}. +func isCrossKeyRef(line, key string, secrets map[string]string) bool { + ref := composeRefPattern.FindString(line) + if ref == "" || !lineValueIs(line, ref) || secrets[key] == ref { + return false + } + m := composeRefName.FindStringSubmatch(ref) + if m == nil || m[1] == key { + return false + } + _, mapped := secrets[m[1]] + return mapped +} + // lineValueIs reports whether the "KEY: value" YAML line decodes to val. A // value holding a quote or a backslash is escaped in the line, so a substring // check misses it and the literal stayed in the file (D-0635). diff --git a/internal/build/secrets_template_test.go b/internal/build/secrets_template_test.go index b6ef891a2..b0f27e3d0 100644 --- a/internal/build/secrets_template_test.go +++ b/internal/build/secrets_template_test.go @@ -571,3 +571,23 @@ func TestTemplateSecretsCustomServiceMinioIdentity(t *testing.T) { } } } + +// A literal secret shaped like a compose reference ("${ABC}") is still a +// literal: it must become ${AUTH_SMTP_PASS}, not an unrelated substitution. +// A real cross-key reference to another mapped secret is preserved. +func TestTemplateSecretsLiteralRefShapedSecret(t *testing.T) { + cfg := secretTestConfig() + cfg.Auth.SMTPPass = "${ABC}" + secrets := SecretEnvMap(cfg) + raw := "services:\n auth:\n environment:\n AUTH_SMTP_PASS: ${ABC}\n" + + " worker:\n environment:\n S3_ACCESS_KEY: ${MINIO_ROOT_USER}\n" + secrets["MINIO_ROOT_USER"] = "minio-root" + secrets["S3_ACCESS_KEY"] = "application-access" + out := string(TemplateSecrets([]byte(raw), secrets)) + if !strings.Contains(out, "AUTH_SMTP_PASS: ${AUTH_SMTP_PASS}") { + t.Errorf("literal ${ABC} SMTP password not templated:\n%s", out) + } + if !strings.Contains(out, "S3_ACCESS_KEY: ${MINIO_ROOT_USER}") { + t.Errorf("cross-key reference not preserved:\n%s", out) + } +} From cb632915ff7a815afb40dcf3a39247efcff3a2ed Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 07:56:49 -0400 Subject: [PATCH 10/13] fix(build): restrict credential aliases and restore CI consistency --- .github/wiki/MCP-Tools.md | 148 ++++++++++----------- .github/wiki/cmd-logs.md | 2 + internal/build/secrets_template_rewrite.go | 31 ++--- internal/build/secrets_template_test.go | 41 ++++++ internal/compose/hasura_image_test.go | 12 +- internal/compose/project_pin_refs.go | 59 ++++++++ internal/compose/project_pins.go | 51 ------- sdk/ts-cli-api/src/generated/types.ts | 10 -- 8 files changed, 196 insertions(+), 158 deletions(-) create mode 100644 internal/compose/project_pin_refs.go diff --git a/.github/wiki/MCP-Tools.md b/.github/wiki/MCP-Tools.md index 418316983..0ba4be01a 100644 --- a/.github/wiki/MCP-Tools.md +++ b/.github/wiki/MCP-Tools.md @@ -229,67 +229,67 @@ The tools `nself mcp` serves in v1.5 mode, one per exposed command of the machin | `nself_deploy_targets` | List deploy targets without connecting to them | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21202) | | `nself_deploy_web` | Build web apps locally and deploy prebuilt output to Vercel | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21337) | | `nself_doctor` | Run comprehensive system diagnostics | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21476) | -| `nself_doctor_heal` | Run targeted self-healing routines for nSelf components | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21680) | -| `nself_doctor_images` | Check availability of every locked image | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21843) | -| `nself_doctor_report` | Write a redacted support bundle (tar.gz) to share in an issue | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21921) | -| `nself_doctor_security_audit` | Run a read-only security audit | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21988) | -| `nself_doctor_security_status` | Show current security posture | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22063) | -| `nself_help_capabilities` | Search commands and plugins by what you want to do | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22401) | -| `nself_help_topics` | Moved to nself help topics | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22495) | -| `nself_init` | Initialize a new nSelf project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22571) | -| `nself_init_template` | Browse and publish full-stack app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22735) | -| `nself_init_template_info` | Show detail for a single template | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22798) | -| `nself_init_template_list` | List available app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22910) | -| `nself_init_template_publish` | Submit a template to the nSelf registry | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23084) | -| `nself_init_template_update` | Apply incremental migrations for the installed template | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23206) | -| `nself_license` | Manage license keys for nSelf product bundles | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23269) | -| `nself_license_account` | Manage your nSelf account, sessions, licenses, team, and devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23332) | -| `nself_license_account_devices` | List and revoke registered devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23410) | -| `nself_license_account_licenses` | List and manage account licenses | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23490) | -| `nself_license_account_status` | Show current account summary | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23586) | -| `nself_license_account_team` | Manage team members | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23664) | -| `nself_license_add` | Add one or more license keys | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23734) | -| `nself_license_clear` | Remove all saved license keys | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23808) | -| `nself_license_health` | Validate format, ping server, and report cache integrity | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23919) | -| `nself_license_import` | Import a previously exported license cache file | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23986) | -| `nself_license_list` | Show all configured licenses (alias for status) | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24044) | -| `nself_license_migrate` | Migrate legacy license key to a nSelf account | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24170) | -| `nself_license_refresh` | Force-refresh license validation against ping.nself.org | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24346) | -| `nself_license_remove` | Remove a license key by value or product name | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24452) | -| `nself_license_restore` | Reactivate dormant plugins with a new license key | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24561) | -| `nself_license_revalidate` | Force a fresh validation against ping.nself.org and update the cache | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24673) | -| `nself_license_revoke` | Mark local license as revoked and wipe the stored key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24720) | -| `nself_license_set` | Replace all keys with a single key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24823) | -| `nself_license_show` | Display license tier, bundles, plugins, and cache expiry | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24940) | -| `nself_license_simulate_offline` | Simulate being offline for N days (testing only) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25015) | -| `nself_license_status` | Show all configured licenses and plugin coverage | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25093) | -| `nself_license_upgrade` | Open pricing page in browser | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25219) | -| `nself_license_validate` | Validate key against ping.nself.org | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25267) | -| `nself_logs` | View and filter service logs | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25317) | -| `nself_mcp_setup` | Print or write the MCP client config and AGENTS.md for this project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25492) | -| `nself_remove` | Remove an installed plugin or bundle | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25779) | -| `nself_reset` | Stop containers, remove all data volumes, and clean generated files | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25943) | -| `nself_restart` | Smart restart with config change detection | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26067) | -| `nself_start` | Boot your nSelf stack | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26259) | -| `nself_status` | Show health status of all services | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26426) | -| `nself_status_health` | Health check management with continuous monitoring | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26555) | -| `nself_status_health_check` | Run all health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26674) | -| `nself_status_health_config` | Show health check settings | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26761) | -| `nself_status_health_endpoint` | Check an HTTP endpoint | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26825) | -| `nself_status_health_history` | Show last 20 health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26887) | -| `nself_status_health_service` | Check a single service | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26989) | -| `nself_status_urls` | Display all service URLs with route conflict detection | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27051) | -| `nself_stop` | Gracefully shut down all services or specific services | `destructive` | with `--volumes` | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27504) | -| `nself_update` | Update the nSelf CLI and admin UI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27655) | -| `nself_update_images` | Refresh Docker image digests from registries | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27938) | -| `nself_update_project_detect` | Detect v1 artifacts in the current project | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28029) | -| `nself_update_project_from_bash` | Migrate a v0.9.9 Bash-era project to the current nSelf CLI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28081) | -| `nself_update_project_from_v099` | Migrate v0.9.9 home-level state (license key, channel, ssh keys) to v1.x layout | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28144) | -| `nself_update_project_rollback` | Restore a v1 backup created by migrate run | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28207) | -| `nself_update_project_run` | Migrate v1 project to v2 | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28329) | -| `nself_update_stack` | Upgrade the stack with a verified backup and resumable steps | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28381) | -| `nself_update_upgrade` | Upgrade the nSelf CLI (detects install method) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28533) | -| `nself_update_verify_sbom` | Verify the SBOM signature for a CLI release | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28619) | +| `nself_doctor_heal` | Run targeted self-healing routines for nSelf components | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21670) | +| `nself_doctor_images` | Check availability of every locked image | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21833) | +| `nself_doctor_report` | Write a redacted support bundle (tar.gz) to share in an issue | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21911) | +| `nself_doctor_security_audit` | Run a read-only security audit | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L21978) | +| `nself_doctor_security_status` | Show current security posture | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22053) | +| `nself_help_capabilities` | Search commands and plugins by what you want to do | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22391) | +| `nself_help_topics` | Moved to nself help topics | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22485) | +| `nself_init` | Initialize a new nSelf project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22561) | +| `nself_init_template` | Browse and publish full-stack app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22725) | +| `nself_init_template_info` | Show detail for a single template | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22788) | +| `nself_init_template_list` | List available app templates | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22900) | +| `nself_init_template_publish` | Submit a template to the nSelf registry | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23074) | +| `nself_init_template_update` | Apply incremental migrations for the installed template | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23196) | +| `nself_license` | Manage license keys for nSelf product bundles | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23259) | +| `nself_license_account` | Manage your nSelf account, sessions, licenses, team, and devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23322) | +| `nself_license_account_devices` | List and revoke registered devices | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23400) | +| `nself_license_account_licenses` | List and manage account licenses | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23480) | +| `nself_license_account_status` | Show current account summary | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23576) | +| `nself_license_account_team` | Manage team members | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23654) | +| `nself_license_add` | Add one or more license keys | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23724) | +| `nself_license_clear` | Remove all saved license keys | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23798) | +| `nself_license_health` | Validate format, ping server, and report cache integrity | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23909) | +| `nself_license_import` | Import a previously exported license cache file | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L23976) | +| `nself_license_list` | Show all configured licenses (alias for status) | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24034) | +| `nself_license_migrate` | Migrate legacy license key to a nSelf account | `remote` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24160) | +| `nself_license_refresh` | Force-refresh license validation against ping.nself.org | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24336) | +| `nself_license_remove` | Remove a license key by value or product name | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24442) | +| `nself_license_restore` | Reactivate dormant plugins with a new license key | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24551) | +| `nself_license_revalidate` | Force a fresh validation against ping.nself.org and update the cache | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24663) | +| `nself_license_revoke` | Mark local license as revoked and wipe the stored key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24710) | +| `nself_license_set` | Replace all keys with a single key | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24813) | +| `nself_license_show` | Display license tier, bundles, plugins, and cache expiry | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L24930) | +| `nself_license_simulate_offline` | Simulate being offline for N days (testing only) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25005) | +| `nself_license_status` | Show all configured licenses and plugin coverage | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25083) | +| `nself_license_upgrade` | Open pricing page in browser | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25209) | +| `nself_license_validate` | Validate key against ping.nself.org | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25257) | +| `nself_logs` | View and filter service logs | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25307) | +| `nself_mcp_setup` | Print or write the MCP client config and AGENTS.md for this project | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25467) | +| `nself_remove` | Remove an installed plugin or bundle | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25754) | +| `nself_reset` | Stop containers, remove all data volumes, and clean generated files | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25918) | +| `nself_restart` | Smart restart with config change detection | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26042) | +| `nself_start` | Boot your nSelf stack | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26234) | +| `nself_status` | Show health status of all services | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26401) | +| `nself_status_health` | Health check management with continuous monitoring | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26530) | +| `nself_status_health_check` | Run all health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26649) | +| `nself_status_health_config` | Show health check settings | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26736) | +| `nself_status_health_endpoint` | Check an HTTP endpoint | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26800) | +| `nself_status_health_history` | Show last 20 health checks | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26862) | +| `nself_status_health_service` | Check a single service | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26964) | +| `nself_status_urls` | Display all service URLs with route conflict detection | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27026) | +| `nself_stop` | Gracefully shut down all services or specific services | `destructive` | with `--volumes` | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27479) | +| `nself_update` | Update the nSelf CLI and admin UI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27630) | +| `nself_update_images` | Refresh Docker image digests from registries | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L27913) | +| `nself_update_project_detect` | Detect v1 artifacts in the current project | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28004) | +| `nself_update_project_from_bash` | Migrate a v0.9.9 Bash-era project to the current nSelf CLI | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28056) | +| `nself_update_project_from_v099` | Migrate v0.9.9 home-level state (license key, channel, ssh keys) to v1.x layout | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28119) | +| `nself_update_project_rollback` | Restore a v1 backup created by migrate run | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28182) | +| `nself_update_project_run` | Migrate v1 project to v2 | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28304) | +| `nself_update_stack` | Upgrade the stack with a verified backup and resumable steps | `destructive` | yes | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28356) | +| `nself_update_upgrade` | Upgrade the nSelf CLI (detects install method) | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28508) | +| `nself_update_verify_sbom` | Verify the SBOM signature for a CLI release | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28594) | ## Legacy aliases @@ -297,18 +297,18 @@ Deprecated names from v1.4, removed in v1.6.0. Each maps its parameters onto the | Name | Summary | Side effect | Confirm required | Input schema | |---|---|---|---|---| -| `nself_env_list` | Deprecated: use nself_config_env_list; removed in v1.6.0. List available environments. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22142) | -| `nself_get_permissions` | Deprecated: use nself_db_hasura_permissions; removed in v1.6.0. Read Hasura table permissions. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22193) | -| `nself_get_schema` | Deprecated: use nself_db_hasura_schema; removed in v1.6.0. Read the Hasura GraphQL schema. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22245) | -| `nself_plugin_install` | Deprecated: use nself_add; removed in v1.6.0. Install a plugin or bundle. | `remote` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25564) | -| `nself_plugin_list` | Deprecated: use nself_config_plugins_list; removed in v1.6.0. List available and installed plugins. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25687) | -| `nself_run_migration` | Deprecated: removed in v1.5. nself_run_migration ran raw SQL outside the migration ledger and is removed in v1.5; use nself db migrate create then nself db migrate up. | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26154) | -| `nself_service_list` | Deprecated: use nself_config_services_list; removed in v1.6.0. List all optional services with enabled/disabled status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26183) | -| `nself_urls` | Deprecated: use nself_status_urls; removed in v1.6.0. Display all service URLs with route conflict detection. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28682) | -| `sentry_incidents_ack` | Deprecated: use nself_sentry_incidents_ack; removed in v1.6.0. Acknowledge an ɳSentry incident. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29102) | -| `sentry_incidents_list` | Deprecated: use nself_sentry_incidents_list; removed in v1.6.0. List ɳSentry incidents. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29130) | -| `sentry_monitors_add` | Deprecated: use nself_sentry_monitors_add; removed in v1.6.0. Add an ɳSentry uptime monitor. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29155) | -| `sentry_monitors_list` | Deprecated: use nself_sentry_monitors_list; removed in v1.6.0. List ɳSentry uptime monitors. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29195) | -| `sentry_status` | Deprecated: use nself_sentry_status; removed in v1.6.0. Read the public ɳSentry status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29215) | +| `nself_env_list` | Deprecated: use nself_config_env_list; removed in v1.6.0. List available environments. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22132) | +| `nself_get_permissions` | Deprecated: use nself_db_hasura_permissions; removed in v1.6.0. Read Hasura table permissions. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22183) | +| `nself_get_schema` | Deprecated: use nself_db_hasura_schema; removed in v1.6.0. Read the Hasura GraphQL schema. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L22235) | +| `nself_plugin_install` | Deprecated: use nself_add; removed in v1.6.0. Install a plugin or bundle. | `remote` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25539) | +| `nself_plugin_list` | Deprecated: use nself_config_plugins_list; removed in v1.6.0. List available and installed plugins. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L25662) | +| `nself_run_migration` | Deprecated: removed in v1.5. nself_run_migration ran raw SQL outside the migration ledger and is removed in v1.5; use nself db migrate create then nself db migrate up. | `destructive` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26129) | +| `nself_service_list` | Deprecated: use nself_config_services_list; removed in v1.6.0. List all optional services with enabled/disabled status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L26158) | +| `nself_urls` | Deprecated: use nself_status_urls; removed in v1.6.0. Display all service URLs with route conflict detection. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L28657) | +| `sentry_incidents_ack` | Deprecated: use nself_sentry_incidents_ack; removed in v1.6.0. Acknowledge an ɳSentry incident. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29077) | +| `sentry_incidents_list` | Deprecated: use nself_sentry_incidents_list; removed in v1.6.0. List ɳSentry incidents. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29105) | +| `sentry_monitors_add` | Deprecated: use nself_sentry_monitors_add; removed in v1.6.0. Add an ɳSentry uptime monitor. | `write` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29130) | +| `sentry_monitors_list` | Deprecated: use nself_sentry_monitors_list; removed in v1.6.0. List ɳSentry uptime monitors. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29170) | +| `sentry_status` | Deprecated: use nself_sentry_status; removed in v1.6.0. Read the public ɳSentry status. | `read` | no | [input schema](https://github.com/nself-org/cli/blob/main/internal/mcpgen/testdata/tools.golden.json#L29190) | **Plugin commands.** Installed plugins add tools by the same `ToolName` rule. This page is generated from the builtin registry alone, so it never lists them; each plugin's wiki page lists its own in its machine-surface region, for example the [CI plugin page](https://github.com/nself-org/plugins/wiki/CI). diff --git a/.github/wiki/cmd-logs.md b/.github/wiki/cmd-logs.md index 760d1f6de..a4b14c10a 100644 --- a/.github/wiki/cmd-logs.md +++ b/.github/wiki/cmd-logs.md @@ -21,6 +21,8 @@ nself logs [SERVICE] [flags] Output is colorized by level: errors in red, warnings in yellow, info in green. Use `--follow` for live streaming; press Ctrl+C to exit cleanly. Use `--quiet` to suppress healthcheck noise and focus on application-level events. Use `--plain` or redirect stdout to a file when piping output to other tools. +In v1.5 JSON output, log records preserve message indentation and trailing spaces. Search, regex, and level filters examine the message without the Compose service prefix. With `--follow`, each record is emitted as an NDJSON envelope. In v1.4, `--json` retains legacy per-line JSON. + The `--status`, `--summary`, and `--top` flags provide aggregate views without raw log output, useful for quick operational checks. ## Exit Codes diff --git a/internal/build/secrets_template_rewrite.go b/internal/build/secrets_template_rewrite.go index c3c44251b..f78562efa 100644 --- a/internal/build/secrets_template_rewrite.go +++ b/internal/build/secrets_template_rewrite.go @@ -146,26 +146,23 @@ func replaceOutsideRefs(s, old, repl string) string { return sb.String() } -// composeRefName captures the variable name of a whole-value ${NAME} or -// ${NAME:-...} reference. -var composeRefName = regexp.MustCompile(`^\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-.*)?\}$`) - -// isCrossKeyRef reports whether the "KEY: value" line holds a deliberate -// reference to a different mapped secret (CS_N S3_ACCESS_KEY: ${MINIO_ROOT_USER}). -// A value that merely looks like a reference, such as a literal SMTP password -// "${ABC}", names no mapped secret, or is the configured secret itself, and -// stays a literal so it is still rewritten to ${KEY}. +// isCrossKeyRef recognises the credential sources selected by the generator. +// Reference syntax alone cannot distinguish an intentional reference from a +// literal password. Only the MinIO identity aliases are generated cross-key; +// even these must be rewritten when the configured secret equals the value. func isCrossKeyRef(line, key string, secrets map[string]string) bool { - ref := composeRefPattern.FindString(line) - if ref == "" || !lineValueIs(line, ref) || secrets[key] == ref { - return false - } - m := composeRefName.FindStringSubmatch(ref) - if m == nil || m[1] == key { + var source string + switch key { + case "S3_ACCESS_KEY": + source = "MINIO_ROOT_USER" + case "S3_SECRET_KEY": + source = "MINIO_ROOT_PASSWORD" + default: return false } - _, mapped := secrets[m[1]] - return mapped + ref := "${" + source + "}" + _, mapped := secrets[source] + return mapped && secrets[key] != ref && lineValueIs(line, ref) } // lineValueIs reports whether the "KEY: value" YAML line decodes to val. A diff --git a/internal/build/secrets_template_test.go b/internal/build/secrets_template_test.go index b0f27e3d0..992d6956c 100644 --- a/internal/build/secrets_template_test.go +++ b/internal/build/secrets_template_test.go @@ -591,3 +591,44 @@ func TestTemplateSecretsLiteralRefShapedSecret(t *testing.T) { t.Errorf("cross-key reference not preserved:\n%s", out) } } + +// Exercise generated YAML quoting as well as unknown and mapped variable names. +func TestTemplateSecretsGeneratedSMTPReferenceLiterals(t *testing.T) { + for _, password := range []string{"${ABC}", "${MINIO_ROOT_USER}", "${MINIO_ROOT_USER:-fallback}", "prefix${ABC}"} { + t.Run(password, func(t *testing.T) { + cfg := secretTestConfig() + cfg.Auth.SMTPPass = password + raw, err := compose.NewGenerator(cfg).Generate() + if err != nil { + t.Fatal(err) + } + templated := TemplateSecrets(raw, SecretEnvMap(cfg)) + var doc struct { + Services map[string]struct { + Environment map[string]string `yaml:"environment"` + } `yaml:"services"` + } + if err := yaml.Unmarshal(templated, &doc); err != nil { + t.Fatal(err) + } + if got := doc.Services["auth"].Environment["AUTH_SMTP_PASS"]; got != "${AUTH_SMTP_PASS}" { + t.Errorf("SMTP password reference = %q, want ${AUTH_SMTP_PASS}", got) + } + }) + } +} + +func TestTemplateSecretsLiteralMinioAlias(t *testing.T) { + for _, key := range []string{"S3_ACCESS_KEY", "S3_SECRET_KEY"} { + source := "MINIO_ROOT_USER" + if key == "S3_SECRET_KEY" { + source = "MINIO_ROOT_PASSWORD" + } + ref := "${" + source + "}" + raw := []byte("environment:\n " + key + ": '" + ref + "'\n") + got := string(TemplateSecrets(raw, map[string]string{key: ref, source: "root-value"})) + if !strings.Contains(got, key+": ${"+key+"}") { + t.Errorf("literal alias was preserved: %s", got) + } + } +} diff --git a/internal/compose/hasura_image_test.go b/internal/compose/hasura_image_test.go index 0789cef80..b36509f2a 100644 --- a/internal/compose/hasura_image_test.go +++ b/internal/compose/hasura_image_test.go @@ -168,15 +168,15 @@ func TestHasuraInitUpgradeKeepsNewerPin(t *testing.T) { v14, _ := LockedRef("hasura-v14") setMode(t, PinningLock) defer SetProjectPins(nil)() -compattest.Set(t, true) -t.Setenv("HASURA_VERSION", v14.Version) + compattest.Set(t, true) + t.Setenv("HASURA_VERSION", v14.Version) // Without a pin, v2.44.0 is explicit and resolves to hasura-v14. if got := hasuraImageFor(t, v14.Version); got != v14.String() { t.Errorf("v1.5 without pin, HASURA_VERSION=%s: hasura image = %q, want explicit v14 %q", v14.Version, got, v14.String()) -} + } // v1.4 also treats v2.44.0 as explicit (it matches hasura-v14's default). -compattest.Set(t, false) -if got := hasuraImageFor(t, v14.Version); got != v14.String() { + compattest.Set(t, false) + if got := hasuraImageFor(t, v14.Version); got != v14.String() { t.Errorf("v1.4 without pin, HASURA_VERSION=%s: hasura image = %q, want explicit v14 %q", v14.Version, got, v14.String()) -} + } } diff --git a/internal/compose/project_pin_refs.go b/internal/compose/project_pin_refs.go new file mode 100644 index 000000000..c81ac909e --- /dev/null +++ b/internal/compose/project_pin_refs.go @@ -0,0 +1,59 @@ +package compose + +// Reference comparison and notices for project image pins. + +import ( + "fmt" + "strings" +) + +// repoOf is CanonicalRef without the tag. +func repoOf(ref string) string { + ref = CanonicalRef(ref) + if i := strings.LastIndex(ref, ":"); i > strings.LastIndex(ref, "/") { + ref = ref[:i] + } + return ref +} + +// noticePinDropped prints the drop notice once per service, variable and pin. +func noticePinDropped(service, envName, pin string) { + key := service + "/" + envName + "/" + pin + pinNoticedMu.Lock() + seen := pinNoticed[key] + pinNoticed[key] = true + w := pinNoticeW + pinNoticedMu.Unlock() + if seen || w == nil { + return + } + if envName == "the rendered image" { + _, _ = fmt.Fprintf(w, "notice: the %s image is now a different repository than its pin, so the pin in %s is not used\n", service, ProjectLockFile) + return + } + _, _ = fmt.Fprintf(w, "notice: %s is set, so the %s pin in %s is not used\n", envName, service, ProjectLockFile) +} + +func imageBase(ref string) string { + base, _, _ := strings.Cut(ref, "@") + return base +} + +// CanonicalRef reduces an image reference to one spelling for comparison: no +// digest, no docker.io/, index.docker.io/ or library/ prefix, host and path in +// lower case, the tag as written (tags are case-sensitive). The bootstrap pins +// a reference as docker.io/:@sha256 while the legacy image strings +// omit the host, so every comparison of two spellings goes through here +// (internal/docker cannot be imported: its tests import this package). +func CanonicalRef(ref string) string { + ref = strings.TrimSpace(imageBase(ref)) + tag := "" + if i := strings.LastIndex(ref, ":"); i > strings.LastIndex(ref, "/") { + ref, tag = ref[:i], ref[i:] + } + ref = strings.ToLower(ref) + for _, host := range []string{"registry-1.docker.io/", "index.docker.io/", "docker.io/"} { + ref = strings.TrimPrefix(ref, host) + } + return strings.TrimPrefix(ref, "library/") + tag +} diff --git a/internal/compose/project_pins.go b/internal/compose/project_pins.go index 033a9c0d6..01defd224 100644 --- a/internal/compose/project_pins.go +++ b/internal/compose/project_pins.go @@ -205,57 +205,6 @@ func envDigestDiffers(in ImageInput, pin string) string { return "" } -// repoOf is CanonicalRef without the tag. -func repoOf(ref string) string { - ref = CanonicalRef(ref) - if i := strings.LastIndex(ref, ":"); i > strings.LastIndex(ref, "/") { - ref = ref[:i] - } - return ref -} - -// noticePinDropped prints the drop notice once per service, variable and pin. -func noticePinDropped(service, envName, pin string) { - key := service + "/" + envName + "/" + pin - pinNoticedMu.Lock() - seen := pinNoticed[key] - pinNoticed[key] = true - w := pinNoticeW - pinNoticedMu.Unlock() - if seen || w == nil { - return - } - if envName == "the rendered image" { - _, _ = fmt.Fprintf(w, "notice: the %s image is now a different repository than its pin, so the pin in %s is not used\n", service, ProjectLockFile) - return - } - _, _ = fmt.Fprintf(w, "notice: %s is set, so the %s pin in %s is not used\n", envName, service, ProjectLockFile) -} - -func imageBase(ref string) string { - base, _, _ := strings.Cut(ref, "@") - return base -} - -// CanonicalRef reduces an image reference to one spelling for comparison: no -// digest, no docker.io/, index.docker.io/ or library/ prefix, host and path in -// lower case, the tag as written (tags are case-sensitive). The bootstrap pins -// a reference as docker.io/:@sha256 while the legacy image strings -// omit the host, so every comparison of two spellings goes through here -// (internal/docker cannot be imported: its tests import this package). -func CanonicalRef(ref string) string { - ref = strings.TrimSpace(imageBase(ref)) - tag := "" - if i := strings.LastIndex(ref, ":"); i > strings.LastIndex(ref, "/") { - ref, tag = ref[:i], ref[i:] - } - ref = strings.ToLower(ref) - for _, host := range []string{"registry-1.docker.io/", "index.docker.io/", "docker.io/"} { - ref = strings.TrimPrefix(ref, host) - } - return strings.TrimPrefix(ref, "library/") + tag -} - // CLILockedRef finds the CLI image lock entry whose version a tag reference // names, in the short or the docker.io/ spelling. The pre-rename // nhost/hasura-auth spelling names nhost/auth (same digests, P7-LIVE-20). diff --git a/sdk/ts-cli-api/src/generated/types.ts b/sdk/ts-cli-api/src/generated/types.ts index 3459ca094..0cb29d4b4 100644 --- a/sdk/ts-cli-api/src/generated/types.ts +++ b/sdk/ts-cli-api/src/generated/types.ts @@ -5376,8 +5376,6 @@ export interface DoctorData { } export type DoctorFlags = { - /** Run the AI first-run wizard (install Ollama, setup Gemini pool, verify) */ - ai?: boolean; /** Check monitoring alert rules are loaded */ alerts?: boolean; /** Scan host for v0.9 stale paths (global scan, not per-project) */ @@ -5394,8 +5392,6 @@ export type DoctorFlags = { full?: boolean; /** Print OAuth URL instead of opening browser (for SSH/headless servers) */ headless?: boolean; - /** Run 6-stage onboarding funnel check (used by Homebrew post-install hook) */ - "install-check"?: boolean; /** Run only a specific subsystem check (host, docker, postgres, hasura, nginx, ssl, ping, plugins, license, monitoring, backups, security) */ only?: string; /** With --fix, show generated-file repair plan without writing */ @@ -6282,14 +6278,8 @@ export type LogsFlags = { service?: Array; /** Show logs since (e.g. 1h, 30m, or RFC3339) */ since?: string; - /** Show service status overview */ - status?: boolean; - /** Show recent errors by service */ - summary?: boolean; /** Number of lines to show */ tail?: number; - /** Show most active services */ - top?: boolean; /** Show logs until (e.g. 1h or RFC3339) */ until?: string; }; From 06d69cb3d78ef335e20ecbaf4e2fbcd42ebff5e8 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 08:09:12 -0400 Subject: [PATCH 11/13] test(commands): isolate config fixtures from inherited project values --- cmd/commands/config_fixture_env_test.go | 21 +++++++++++++++++++++ cmd/commands/next_step_test.go | 1 + cmd/commands/rebuild_cascade_test.go | 2 +- 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 cmd/commands/config_fixture_env_test.go diff --git a/cmd/commands/config_fixture_env_test.go b/cmd/commands/config_fixture_env_test.go new file mode 100644 index 000000000..2fcb36bc7 --- /dev/null +++ b/cmd/commands/config_fixture_env_test.go @@ -0,0 +1,21 @@ +package commands + +import ( + "os" + "testing" + + "github.com/nself-org/cli/internal/build" + "github.com/nself-org/cli/internal/config" +) + +// isolateConfigFixture prevents config.Load's exported values from earlier +// projects from enabling services or invalidating this fixture's credentials. +func isolateConfigFixture(t *testing.T) { + t.Helper() + t.Cleanup(build.SnapshotEnv()) + for _, key := range config.KnownEnvVars() { + if err := os.Unsetenv(key); err != nil { + t.Fatal(err) + } + } +} diff --git a/cmd/commands/next_step_test.go b/cmd/commands/next_step_test.go index 9238de9e5..2635aaae6 100644 --- a/cmd/commands/next_step_test.go +++ b/cmd/commands/next_step_test.go @@ -79,6 +79,7 @@ func init() { func nextStepFixture(t *testing.T, stage string) { t.Helper() + isolateConfigFixture(t) dir := t.TempDir() t.Setenv("HOME", filepath.Join(dir, "home")) if err := os.MkdirAll(filepath.Join(dir, "home"), 0o755); err != nil { diff --git a/cmd/commands/rebuild_cascade_test.go b/cmd/commands/rebuild_cascade_test.go index f2427a1d6..6f5ceb22d 100644 --- a/cmd/commands/rebuild_cascade_test.go +++ b/cmd/commands/rebuild_cascade_test.go @@ -36,7 +36,7 @@ func TestRebuildCascade(t *testing.T) { for _, scenario := range []string{"prod_class_refusal", "hand_edit_refused_then_force"} { for _, caller := range callers { t.Run(scenario+"/"+caller.name, func(t *testing.T) { - t.Cleanup(build.SnapshotEnv()) + isolateConfigFixture(t) isolateDoctorHome(t) if runtime.GOOS == "windows" && caller.name == "restart_smart" { t.Skip("the docker stub is a POSIX shell script; a real docker.exe answers instead") From c04472cc9119e6bb966720c3ab860605faa0d597 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 10:35:06 -0400 Subject: [PATCH 12/13] test(build): pin v1.4 for generated SMTP reference-literal test Core MinIO exists only in v1.4, so the compose generator returns E138 under NSELF_V15=1 (the CI v1.5 leg). Pin compat mode like the sibling generate-and-template test. --- internal/build/secrets_template_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/build/secrets_template_test.go b/internal/build/secrets_template_test.go index 992d6956c..257c6add8 100644 --- a/internal/build/secrets_template_test.go +++ b/internal/build/secrets_template_test.go @@ -594,6 +594,7 @@ func TestTemplateSecretsLiteralRefShapedSecret(t *testing.T) { // Exercise generated YAML quoting as well as unknown and mapped variable names. func TestTemplateSecretsGeneratedSMTPReferenceLiterals(t *testing.T) { + compattest.Set(t, false) // core MinIO exists only in v1.4 (P7-PLUG-60) for _, password := range []string{"${ABC}", "${MINIO_ROOT_USER}", "${MINIO_ROOT_USER:-fallback}", "prefix${ABC}"} { t.Run(password, func(t *testing.T) { cfg := secretTestConfig() From a70e38d560e5772a704a6eaade892482c04dbded Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 11:28:41 -0400 Subject: [PATCH 13/13] refactor(build): move postgres image warning to internal/postgres to hold the package-size ceiling --- internal/build/orchestrator_build_compose.go | 5 +-- .../image_warning.go} | 16 +++++----- .../image_warning_test.go} | 32 +++++++++---------- .../repoqa/testdata/docker-exec-allowlist.txt | 2 +- .../testdata/package-size-allowlist.txt | 2 +- 5 files changed, 29 insertions(+), 28 deletions(-) rename internal/{build/postgres_image_warning.go => postgres/image_warning.go} (82%) rename internal/{build/postgres_image_warning_test.go => postgres/image_warning_test.go} (50%) diff --git a/internal/build/orchestrator_build_compose.go b/internal/build/orchestrator_build_compose.go index dd0c52de7..b8e5cd615 100644 --- a/internal/build/orchestrator_build_compose.go +++ b/internal/build/orchestrator_build_compose.go @@ -24,6 +24,7 @@ import ( "github.com/nself-org/cli/internal/compose" "github.com/nself-org/cli/internal/compose/monitoring" "github.com/nself-org/cli/internal/errs" + "github.com/nself-org/cli/internal/postgres" ) // generateCompose runs Steps 8-9.8 of Build(). @@ -53,12 +54,12 @@ func (st *buildState) generateCompose() error { // ── Step 8.1: Warn if this regen would swap a running postgres image ── // (cli#384 — advisory only, never blocks the build; see - // PostgresImageChangeWarning for the data-loss scenario this guards.) + // postgres.ImageChangeWarning for the data-loss scenario this guards.) postgresContainer := st.cfg.ServiceContainerName("postgres") generatedPostgresImage := compose.ResolveImage("postgres", compose.ResolvePostgresImage(st.cfg.Postgres)) // Plan mode skips it: the check runs `docker inspect`, a host effect. if !st.planning() { - if warning := PostgresImageChangeWarning(postgresContainer, generatedPostgresImage); warning != "" { + if warning := postgres.ImageChangeWarning(postgresContainer, generatedPostgresImage); warning != "" { slog.Warn(warning) } } diff --git a/internal/build/postgres_image_warning.go b/internal/postgres/image_warning.go similarity index 82% rename from internal/build/postgres_image_warning.go rename to internal/postgres/image_warning.go index a3c92f590..5b3449b33 100644 --- a/internal/build/postgres_image_warning.go +++ b/internal/postgres/image_warning.go @@ -1,6 +1,6 @@ -package build +package postgres -// postgres_image_warning.go — warns when a build-time postgres image would +// image_warning.go — warns when a build-time postgres image would // change what a currently running postgres container is using. // // Purpose: cli#384 — `nself build` must never silently regenerate a compose @@ -44,18 +44,18 @@ func runningContainerImage(containerName string) string { return strings.TrimSpace(string(out)) } -// PostgresImageChangeWarning returns a WARNING message when the postgres +// ImageChangeWarning returns a WARNING message when the postgres // image `nself build` is about to generate differs from the image the // currently running postgres container was started from. Returns "" when // there is nothing to warn about (no running container, or the images match). -func PostgresImageChangeWarning(containerName, generatedImage string) string { - return postgresImageChangeWarning(containerName, generatedImage, runningContainerImage) +func ImageChangeWarning(containerName, generatedImage string) string { + return imageChangeWarning(containerName, generatedImage, runningContainerImage) } -// postgresImageChangeWarning is the testable core of -// PostgresImageChangeWarning, parameterized by an image-lookup function so +// imageChangeWarning is the testable core of +// ImageChangeWarning, parameterized by an image-lookup function so // tests can inject a fake baseline without a reachable Docker daemon. -func postgresImageChangeWarning(containerName, generatedImage string, lookup func(string) string) string { +func imageChangeWarning(containerName, generatedImage string, lookup func(string) string) string { running := lookup(containerName) if running == "" || running == generatedImage { return "" diff --git a/internal/build/postgres_image_warning_test.go b/internal/postgres/image_warning_test.go similarity index 50% rename from internal/build/postgres_image_warning_test.go rename to internal/postgres/image_warning_test.go index 6b70be653..db238d7e6 100644 --- a/internal/build/postgres_image_warning_test.go +++ b/internal/postgres/image_warning_test.go @@ -1,32 +1,32 @@ -package build +package postgres import ( "strings" "testing" ) -// TestPostgresImageChangeWarning_NoRunningContainer verifies the advisory +// TestImageChangeWarning_NoRunningContainer verifies the advisory // check never errors or fabricates a warning when the container doesn't // exist (e.g. Docker isn't running, or this is a first-ever build) — a // missing baseline must never block `nself build`. -func TestPostgresImageChangeWarning_NoRunningContainer(t *testing.T) { - got := PostgresImageChangeWarning("nonexistent_project_postgres_container_xyz", "pgvector/pgvector:pg16") +func TestImageChangeWarning_NoRunningContainer(t *testing.T) { + got := ImageChangeWarning("nonexistent_project_postgres_container_xyz", "pgvector/pgvector:pg16") if got != "" { - t.Errorf("PostgresImageChangeWarning(no container) = %q, want empty", got) + t.Errorf("ImageChangeWarning(no container) = %q, want empty", got) } } -// TestPostgresImageChangeWarning_Mismatch verifies cli#384's core case: a +// TestImageChangeWarning_Mismatch verifies cli#384's core case: a // running pgvector container against a regen that would produce a plain // alpine image must produce a WARNING naming both images and the fix. -func TestPostgresImageChangeWarning_Mismatch(t *testing.T) { +func TestImageChangeWarning_Mismatch(t *testing.T) { lookup := func(name string) string { if name == "myproj_postgres" { return "pgvector/pgvector:pg16" } return "" } - got := postgresImageChangeWarning("myproj_postgres", "postgres:16-alpine", lookup) + got := imageChangeWarning("myproj_postgres", "postgres:16-alpine", lookup) if got == "" { t.Fatal("expected a warning, got empty string") } @@ -37,23 +37,23 @@ func TestPostgresImageChangeWarning_Mismatch(t *testing.T) { } } -// TestPostgresImageChangeWarning_Match verifies no warning is raised when the +// TestImageChangeWarning_Match verifies no warning is raised when the // running container's image already matches what this build generated. -func TestPostgresImageChangeWarning_Match(t *testing.T) { +func TestImageChangeWarning_Match(t *testing.T) { lookup := func(string) string { return "pgvector/pgvector:pg16" } - got := postgresImageChangeWarning("myproj_postgres", "pgvector/pgvector:pg16", lookup) + got := imageChangeWarning("myproj_postgres", "pgvector/pgvector:pg16", lookup) if got != "" { - t.Errorf("postgresImageChangeWarning(match) = %q, want empty", got) + t.Errorf("imageChangeWarning(match) = %q, want empty", got) } } -// TestPostgresImageChangeWarning_EmptyBaselineIsSilent verifies an empty +// TestImageChangeWarning_EmptyBaselineIsSilent verifies an empty // lookup result (no running container, or Docker unreachable) is treated as // "nothing to compare", not as a mismatch. -func TestPostgresImageChangeWarning_EmptyBaselineIsSilent(t *testing.T) { +func TestImageChangeWarning_EmptyBaselineIsSilent(t *testing.T) { lookup := func(string) string { return "" } - got := postgresImageChangeWarning("myproj_postgres", "postgres:16-alpine", lookup) + got := imageChangeWarning("myproj_postgres", "postgres:16-alpine", lookup) if got != "" { - t.Errorf("postgresImageChangeWarning(empty baseline) = %q, want empty", got) + t.Errorf("imageChangeWarning(empty baseline) = %q, want empty", got) } } diff --git a/internal/repoqa/testdata/docker-exec-allowlist.txt b/internal/repoqa/testdata/docker-exec-allowlist.txt index e184571e1..75dbd3c37 100644 --- a/internal/repoqa/testdata/docker-exec-allowlist.txt +++ b/internal/repoqa/testdata/docker-exec-allowlist.txt @@ -19,7 +19,6 @@ internal/backup/create_targets.go 3 internal/backup/pitr/restore.go 2 internal/backup/restore_container.go 1 internal/backup/stream_restore_remote.go 1 -internal/build/postgres_image_warning.go 1 internal/builtin/admin/cmd_logs.go 1 internal/builtin/admin/cmd_start.go 1 internal/builtin/admin/cmd_stop.go 1 @@ -52,6 +51,7 @@ internal/model/detector.go 1 internal/plugin/audit.go 1 internal/plugin/image_invalidate.go 1 internal/plugin/schema.go 2 +internal/postgres/image_warning.go 1 internal/security/waf.go 1 internal/tenant/create.go 2 internal/tenant/destroy.go 3 diff --git a/internal/repoqa/testdata/package-size-allowlist.txt b/internal/repoqa/testdata/package-size-allowlist.txt index bbe6037bc..b5c9cf6d1 100644 --- a/internal/repoqa/testdata/package-size-allowlist.txt +++ b/internal/repoqa/testdata/package-size-allowlist.txt @@ -5,7 +5,7 @@ # under 6000. A new package over 6000 is split by domain, not added here. cmd/commands 65362 internal/backup 6279 -internal/build 13575 +internal/build 13530 internal/config 7498 internal/database 10048 internal/deploy 6667