diff --git a/AGENTS.md b/AGENTS.md index 8713d3b..e45ae03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,18 +10,18 @@ invent synonyms. ## Layout -| Path | Role | +| Path | Role | | ------------------------- | ------------------------------------------------------------------------- | -| `flake.nix` | Thin public flake: inputs, `flakeModules.default`, overlay, lib, template | -| `prelude.nix` | Dogfood sidecar (same shape a consumer gets from the wizard) | -| `nix/` | Flake output composition, render checks, Python PTY tests | -| `nix/internal/` | This repo's MOTD/menu/docs identity, imported by `prelude.nix` | -| `src/prelude/` | flake-parts module, options, shell init, fonts | -| `src/cmd/` | Go mains (`motd`, `menu`, `docs`, `title`, `prompt-status`, VT host) | -| `src/internal/` | Go surface implementations (MOTD, menu, docs, wizard) | -| `src/pkg/` | Shared Go (palette, manual viewer, UI primitives) | -| `docs/` | Viewer pages, guides, generated option/showcase markdown | -| `examples/`, `templates/` | Consumer fixtures; evaluated as checks | +| `flake.nix` | Thin public flake: inputs, `flakeModules.default`, overlay, lib, template | +| `prelude.nix` | Dogfood sidecar (same shape a consumer gets from the wizard) | +| `nix/` | Flake output composition, render checks, Python PTY tests | +| `nix/internal/` | This repo's MOTD/menu/docs identity, imported by `prelude.nix` | +| `src/prelude/` | flake-parts module, options, shell init, fonts | +| `src/cmd/` | Go mains (`motd`, `menu`, `docs`, `title`, `prompt-status`, VT host) | +| `src/internal/` | Go surface implementations (MOTD, menu, docs, wizard) | +| `src/pkg/` | Shared Go (palette, manual viewer, UI primitives) | +| `docs/` | Viewer pages, guides, generated option/showcase markdown | +| `examples/`, `templates/` | Consumer fixtures; evaluated as checks | Import `flakeModules.default`, never `src/prelude/module.nix` directly. diff --git a/docs/media/manifest.json b/docs/media/manifest.json index a335d10..a91f45f 100644 --- a/docs/media/manifest.json +++ b/docs/media/manifest.json @@ -1 +1 @@ -{"recordings":{"menu":{"fingerprint":"0935c27b672db4c181fddcbd6b271d59dbd824430f52c308467bdfdfce03190b","outputs":["menu.gif","menu.png"]},"minimal":{"fingerprint":"1ae4645892259e5294a94fbbf04d104957c1c8fd1e7a77b684a8e5cfa9516565","outputs":["minimal.png"]},"motd":{"fingerprint":"5fda1ba63a9670a253386173a777c024188fa82460bb465916a1dbbf5721d19b","outputs":["motd.gif","motd.png"]},"surface":{"fingerprint":"7eb0e901d3731752390ac36a7074cd08fe0e9f60cda72665f5376ba557afbc46","outputs":["surface.png"]}},"version":1} +{"recordings":{"menu":{"fingerprint":"312a958fdae6f494bff254c16921dee101eb6285b72c1a2c0db0c48f41a274aa","outputs":["menu.gif","menu.png"]},"minimal":{"fingerprint":"8772fc1861705a2140b3d7eb71cd3144e44c3b085a3cf25599f8fffbbac58a72","outputs":["minimal.png"]},"motd":{"fingerprint":"df23affbd9405c8aae541a09483f8fc1cff4daae5d522f68db59852e6329405d","outputs":["motd.gif","motd.png"]},"surface":{"fingerprint":"f8fcebb53de86c6d23898ee043d8c965e507f8a587bff81e02e21c863e067ab8","outputs":["surface.png"]}},"version":1} diff --git a/docs/media/menu.gif b/docs/media/menu.gif index 8ac47c0..201d6ba 100644 Binary files a/docs/media/menu.gif and b/docs/media/menu.gif differ diff --git a/docs/media/menu.png b/docs/media/menu.png index 82d96bd..c9c2a18 100644 Binary files a/docs/media/menu.png and b/docs/media/menu.png differ diff --git a/docs/media/minimal.png b/docs/media/minimal.png index 01d3c83..d54680d 100644 Binary files a/docs/media/minimal.png and b/docs/media/minimal.png differ diff --git a/docs/media/motd.gif b/docs/media/motd.gif index 98ffffd..65da099 100644 Binary files a/docs/media/motd.gif and b/docs/media/motd.gif differ diff --git a/docs/media/motd.png b/docs/media/motd.png index 3209988..2a2a357 100644 Binary files a/docs/media/motd.png and b/docs/media/motd.png differ diff --git a/docs/media/surface.png b/docs/media/surface.png index 6bb97d8..fdb2f7b 100644 Binary files a/docs/media/surface.png and b/docs/media/surface.png differ diff --git a/docs/reference/options.md b/docs/reference/options.md index 13315c9..f3b5a22 100644 --- a/docs/reference/options.md +++ b/docs/reference/options.md @@ -340,6 +340,31 @@ null +## prelude\.commands\.\\.group + + + +Menu group override\. When null, the group is inferred from the first +colon segment of the command key (` go:test ` → group ` go `); ungrouped +keys land in ` develop `\. Set this to place a command under a different +group without colon-prefixing the key — useful for Justfile recipes +and other imports where the key name should stay flat\. + + + +*Type:* +null or string + + + +*Default:* + +```nix +null +``` + + + ## prelude\.commands\.\\.invocation @@ -2364,8 +2389,6 @@ unsigned integer, meaning >=0 ## prelude\.motd\.recipes - - Optional project workflows keyed by name for setup, build, test, deploy, and similar work\. Prefer ` steps ` ({ command } | { comment }); legacy ` lines ` are normalized into steps\. @@ -2411,6 +2434,8 @@ attribute set of (submodule) ## prelude\.motd\.recipes\.\\.lines + + Legacy display lines (\# comments / commands)\. Prefer steps\. diff --git a/nix/preflight-hook-pty-test.py b/nix/preflight-hook-pty-test.py new file mode 100644 index 0000000..039141e --- /dev/null +++ b/nix/preflight-hook-pty-test.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +"""Verify independent MOTD activation paths without exported coordination state. + +Two activation paths can run in one prompt-enabled `nix develop`: the consumer +shellHook evaluates preflight, then Prelude's setup hook sources the same init. +That same-shell re-entry must not render a second banner. Direnv evaluates +`.envrc` non-interactively and later hands its exports to an interactive shell; +those separate shells deliberately do not coordinate through exported state. + +Stage 1 emulates prompt-enabled `nix develop`: an interactive Bash evaluates +preflight, skips the guarded setup-hook source, then evaluates preflight again. +The two explicit invocations must each render, while the automatic source stays +quiet. +Stage 2 emulates direnv: a non-interactive shell with DIRENV_IN_ENVRC set evals +the preflight snippet. It must render once without exporting private Prelude +coordination variables. +Stage 3 emulates the shell direnv hands off to: an interactive Bash on a PTY with +that captured environment runs the `prelude hook` trampoline. It must render the +banner once when the init loads, then stay quiet on the next ordinary prompt +because the hook tracks the loaded PRELUDE_INIT path. + +usage: preflight-hook-pty-test.py BASH PRELUDE_INIT PREFLIGHT_SNIPPET HOOK PATH SENTINEL_TEXT +""" + +import errno +import fcntl +import os +import pty +import select +import signal +import struct +import subprocess +import sys +import termios +import time + + +def fail(message: str, output: bytes = b"") -> None: + print(f"preflight-hook PTY smoke: {message}", file=sys.stderr) + if output: + sys.stderr.buffer.write(output + b"\n") + raise SystemExit(1) + + +def set_size(fd: int, columns: int, rows: int) -> None: + size = struct.pack("HHHH", rows, columns, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, size) + + +def read_until_idle(fd: int, timeout: float, idle: float = 0.5) -> bytes: + output = bytearray() + deadline = time.monotonic() + timeout + last = time.monotonic() + while True: + remaining = max(deadline - time.monotonic(), 0.0) + wait = min(max(idle - (time.monotonic() - last), 0.0), remaining or 0.0) + ready, _, _ = select.select([fd], [], [], wait) + if not ready: + if time.monotonic() >= deadline: + break + continue + try: + chunk = os.read(fd, 65536) + except OSError as exc: + if exc.errno == errno.EIO: + break + raise + if not chunk: + break + output.extend(chunk) + last = time.monotonic() + return bytes(output) + + +def terminate(pid: int, fd: int) -> None: + try: + os.close(fd) + except OSError: + pass + try: + os.kill(pid, signal.SIGTERM) + os.waitpid(pid, 0) + except (OSError, ChildProcessError): + pass + + +def base_env(command_path: str, home: str) -> dict[str, str]: + os.makedirs(home, exist_ok=True) + return { + "HOME": home, + "LANG": "C.UTF-8", + "PATH": command_path, + "TERM": "xterm-256color", + "TMPDIR": os.environ.get("TMPDIR", "/tmp"), + } + + +def same_shell_stage( + bash: str, + snippet: str, + init: str, + env: dict[str, str], + sentinel_text: str, +) -> None: + """Verify setup-hook dedupe without suppressing explicit preflight.""" + stage_env = dict(env, PRELUDE_INIT=init) + command = f""" +. "{snippet}" +[ "$PRELUDE_INIT" = "${{_PRELUDE_INIT_LOADED-}}" ] || exit 91 +if [ "$PRELUDE_INIT" != "${{_PRELUDE_INIT_LOADED-}}" ]; then + . "{init}" +fi +. "{snippet}" +""" + result = subprocess.run( + [bash, "--norc", "--noprofile", "-i", "-c", command], + env=stage_env, + capture_output=True, + check=False, + ) + if result.returncode != 0: + fail("same-shell activation exited non-zero", result.stderr) + render_count = result.stderr.count(sentinel_text.encode()) + if render_count != 2: + fail( + f"two explicit preflights rendered the MOTD {render_count} times; expected twice", + result.stderr, + ) + + +def envrc_stage( + bash: str, + snippet: str, + init: str, + env: dict[str, str], + sentinel_text: str, +) -> dict[str, str]: + """Run the preflight snippet the way direnv runs .envrc; return its exports.""" + stage_env = dict(env, PRELUDE_INIT=init, DIRENV_IN_ENVRC="1") + result = subprocess.run( + [bash, "--norc", "--noprofile", "-c", f'. "{snippet}"\nexec env -0'], + env=stage_env, + capture_output=True, + check=False, + ) + if result.returncode != 0: + fail("envrc stage exited non-zero", result.stderr) + if result.stderr.count(sentinel_text.encode()) != 1: + fail("envrc stage did not render exactly one banner", result.stderr) + exported = {} + for entry in result.stdout.split(b"\0"): + if not entry or b"=" not in entry: + continue + name, _, value = entry.partition(b"=") + exported[name.decode()] = value.decode() + private_exports = sorted(name for name in exported if name.startswith("_PRELUDE_")) + if private_exports: + fail(f"envrc stage exported private coordination state: {', '.join(private_exports)}") + return exported + + +def interactive_stage(bash: str, hook: str, init: str, env: dict[str, str]) -> bytes: + """Run an interactive Bash with the `prelude hook` trampoline installed.""" + stage_env = dict(env, PRELUDE_INIT=init, PS1="pflt$ ") + + pid, master = pty.fork() + if pid == 0: + os.execve(bash, [bash, "--norc", "--noprofile", "-i"], stage_env) + + try: + set_size(master, 100, 40) + # Drain the shell's first prompt, printed before the hook exists. + read_until_idle(master, 6.0) + # Sourcing the hook appends _prelude_hook to PROMPT_COMMAND, and Bash + # prints the next prompt as soon as the source returns. Capture that + # activation plus one ordinary prompt to prove the hook does not source + # the same PRELUDE_INIT path repeatedly. + os.write(master, f". {hook}\n".encode()) + output = bytearray(read_until_idle(master, 15.0)) + os.write(master, b"\n") + output += read_until_idle(master, 15.0) + os.write(master, b"exit\n") + read_until_idle(master, 6.0) + return bytes(output) + finally: + terminate(pid, master) + + + + +def main() -> None: + if len(sys.argv) != 7: + fail("usage: BASH PRELUDE_INIT SNIPPET HOOK PATH SENTINEL_TEXT") + bash, init, snippet, hook, command_path, sentinel_text = sys.argv[1:] + + env = base_env(command_path, os.path.join(os.environ.get("TMPDIR", "/tmp"), "preflight-home")) + same_shell_stage(bash, snippet, init, env, sentinel_text) + exported = envrc_stage(bash, snippet, init, env, sentinel_text) + interactive = interactive_stage(bash, hook, init, exported) + render_count = interactive.count(sentinel_text.encode()) + if render_count != 1: + fail( + f"interactive hook rendered the MOTD {render_count} times; expected exactly once", + interactive, + ) + + +if __name__ == "__main__": + main() diff --git a/nix/prompt-final-pty-test.py b/nix/prompt-final-pty-test.py new file mode 100644 index 0000000..733e01c --- /dev/null +++ b/nix/prompt-final-pty-test.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""Verify Blesh prompt final rewrite and POSTEXEC spacing before the next prompt.""" + +import errno +import fcntl +import os +import pty +import select +import shlex +import signal +import struct +import sys +import termios +import time +from typing import NoReturn + +import pyte + + +def fail(message: str, output: bytes = b"") -> NoReturn: + print(f"prompt-final PTY smoke: {message}", file=sys.stderr) + if output: + print(repr(output[-2000:]), file=sys.stderr) + raise SystemExit(1) + + +def set_size(fd: int, columns: int, rows: int) -> None: + size = struct.pack("HHHH", rows, columns, 0, 0) + fcntl.ioctl(fd, termios.TIOCSWINSZ, size) + + +def answer_terminal_queries( + fd: int, output: bytearray, answered: dict[bytes, int] +) -> None: + replies = { + b"\x1b[5n": b"\x1b[0n", + b"\x1b[6n": b"\x1b[1;1R", + b"\x1b[?6n": b"\x1b[?1;1R", + b"\x1b[c": b"\x1b[?1;2c", + b"\x1b[0c": b"\x1b[?1;2c", + b"\x1b[>c": b"\x1b[>0;0;0c", + b"\x1b[>0c": b"\x1b[>0;0;0c", + b"\x1b]10;?\x07": b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\", + b"\x1b]11;?\x07": b"\x1b]11;rgb:0000/0000/0000\x1b\\", + b"\x1b]10;?\x1b\\": b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\", + b"\x1b]11;?\x1b\\": b"\x1b]11;rgb:0000/0000/0000\x1b\\", + } + for query, reply in replies.items(): + observed = output.count(query) + for _ in range(observed - answered.get(query, 0)): + os.write(fd, reply) + answered[query] = observed + + +def read_until_idle( + fd: int, + first_timeout: float = 8.0, + idle: float = 0.35, + terminal: pyte.ByteStream | None = None, +) -> bytes: + output = bytearray() + answered: dict[bytes, int] = {} + deadline = time.monotonic() + first_timeout + last_read = 0.0 + while True: + now = time.monotonic() + if output: + timeout = max(0.0, min(idle - (now - last_read), deadline - now)) + else: + timeout = max(0.0, deadline - now) + if timeout == 0.0: + break + readable, _, _ = select.select([fd], [], [], timeout) + if not readable: + break + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno == errno.EIO: + break + raise + if not chunk: + break + output.extend(chunk) + if terminal is not None: + terminal.feed(chunk) + answer_terminal_queries(fd, output, answered) + last_read = time.monotonic() + return bytes(output) + + +def transact( + fd: int, + payload: bytes, + label: str, + timeout: float = 5.0, + idle: float = 0.35, + terminal: pyte.ByteStream | None = None, +) -> bytes: + os.write(fd, payload) + output = read_until_idle(fd, timeout, idle, terminal) + if not output: + fail(f"{label} emitted no terminal output") + return output + + +def screen_dump(screen: pyte.Screen) -> bytes: + return "\n".join( + f"{row:02d}: {text!r}" for row, text in enumerate(screen.display) + ).encode() + + +def locate_text(screen: pyte.Screen, text: str) -> tuple[int, int]: + for row, line in enumerate(screen.display): + column = line.find(text) + if column >= 0: + return row, column + fail(f"screen contains no {text!r}", screen_dump(screen)) + +def blank_rows_between(screen: pyte.Screen, start_row: int, end_row: int) -> int: + """Count fully blank display rows in (start_row, end_row).""" + if end_row <= start_row + 1: + return 0 + return sum( + 1 + for row in range(start_row + 1, end_row) + if not screen.display[row].strip() + ) + + +def terminate(pid: int, fd: int) -> None: + try: + os.close(fd) + except OSError: + pass + for _ in range(20): + try: + waited, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return + if waited == pid: + return + time.sleep(0.05) + try: + os.killpg(pid, signal.SIGKILL) + except ProcessLookupError: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + return + for _ in range(20): + try: + waited, _ = os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + return + if waited == pid: + return + time.sleep(0.05) + + +def main() -> None: + if len(sys.argv) != 5: + fail("usage: prompt-final-pty-test.py BASH INIT STARSHIP_CONFIG PATH") + bash, init, starship_config, command_path = sys.argv[1:] + + pid, master = pty.fork() + if pid == 0: + environment = os.environ.copy() + environment.update( + { + "BASH_SILENCE_DEPRECATION_WARNING": "1", + "HOME": os.path.join(os.environ.get("TMPDIR", "/tmp"), "pty-home"), + "LANG": "C.UTF-8", + "PATH": command_path, + "PRELUDE_INIT_QUIET": "1", + "STARSHIP_CONFIG": starship_config, + "TERM": "xterm-256color", + "USER": os.environ.get("USER") or "prelude-test", + "XDG_CACHE_HOME": os.path.join( + os.environ.get("TMPDIR", "/tmp"), "pty-cache" + ), + } + ) + os.makedirs(environment["HOME"], exist_ok=True) + os.makedirs(environment["XDG_CACHE_HOME"], exist_ok=True) + os.chdir(environment["HOME"]) + os.execve(bash, [bash, "--noprofile", "--norc", "-i"], environment) + + screen = pyte.Screen(48, 14) + terminal = pyte.ByteStream(screen) + try: + set_size(master, 48, 14) + transact( + master, + f". {shlex.quote(init)}\r".encode(), + "initial render", + 45.0, + 2.0, + terminal, + ) + def assert_prompt_above_status(label: str) -> None: + context_row = -1 + for row, line in enumerate(screen.display): + if "╭" in line: + context_row = row + if context_row < 0: + fail(f"{label}: no framed prompt context row", screen_dump(screen)) + + # Live format: context, stem │, ╰─ input (cursor stays here). + input_row = context_row + 2 + if input_row >= screen.lines or not screen.display[input_row].startswith( + "╰─ " + ): + fail( + f"{label}: editable prompt is not on ╰─ two rows below context", + screen_dump(screen), + ) + if screen.cursor.y != input_row: + fail( + f"{label}: cursor row {screen.cursor.y} != ╰─ row {input_row}", + screen_dump(screen), + ) + + status_row, _ = locate_text(screen, "Run commands: x ") + # Blank spacer panel is docked immediately above status. + spacer_row = status_row - 1 + if spacer_row <= input_row: + fail( + f"{label}: no room for blank spacer above status " + f"(input={input_row}, status={status_row})", + screen_dump(screen), + ) + if screen.display[spacer_row].strip() != "": + fail( + f"{label}: row immediately above status is not blank: " + f"{screen.display[spacer_row]!r}", + screen_dump(screen), + ) + if status_row != spacer_row + 1: + fail( + f"{label}: status is not adjacent to blank spacer " + f"(spacer={spacer_row}, status={status_row})", + screen_dump(screen), + ) + + assert_prompt_above_status("initial render") + + final_command = ": prelude-final-prompt" + transact(master, final_command.encode(), "final command", terminal=terminal) + transact(master, b"\r", "final prompt rewrite", 10.0, 1.0, terminal) + submitted_row, command_start = locate_text(screen, final_command) + if screen.display[submitted_row][:command_start] != "❯ ": + fail( + "submitted prompt did not collapse to Starship's character", + screen_dump(screen), + ) + + # After a real command, blank-above-status adjacency must hold. + marker = "prelude-gap-mark" + spacing_command = f"printf '%s\\n' {marker}" + transact(master, spacing_command.encode(), "gap command", terminal=terminal) + transact(master, b"\r", "gap command exec", 10.0, 1.5, terminal) + locate_text(screen, marker) + assert_prompt_above_status("after command") + finally: + terminate(pid, master) + + +if __name__ == "__main__": + main() diff --git a/src/internal/portal/config.go b/src/internal/portal/config.go index 887b8f6..6b8948f 100644 --- a/src/internal/portal/config.go +++ b/src/internal/portal/config.go @@ -43,6 +43,15 @@ type App struct { // Environment is one deployment of an app. type Environment struct { Name string `json:"name"` + // Headers are sent with the probe verbatim. For non-secret things: an API + // version, a tenant id, a bypass header a staging proxy expects. + Headers map[string]string `json:"headers"` + // HeadersFromEnv maps a header name to the environment variable holding its + // value. Anything secret belongs here rather than in Headers: the catalogue + // is generated from a Nix file in a git repo, and a credential in it is a + // credential in the repo. Missing variables are skipped, so an unconfigured + // shell probes anonymously instead of sending an empty header. + HeadersFromEnv map[string]string `json:"headersFromEnv"` // URL is what gets opened and, unless Health is set, what gets probed. URL string `json:"url"` // Health overrides the probe target. A UI route often answers 200 for an diff --git a/src/internal/portal/model_test.go b/src/internal/portal/model_test.go index 221badd..c2b6da0 100644 --- a/src/internal/portal/model_test.go +++ b/src/internal/portal/model_test.go @@ -185,3 +185,61 @@ func TestProbeKeepsNotFoundDown(t *testing.T) { t.Fatalf("got state %q, want %q", status.State, StateDown) } } + +// Declared headers reach the probe, and env-sourced ones are read by the name +// the catalogue asked for — the portal never goes looking on its own. +func TestProbeSendsDeclaredHeaders(t *testing.T) { + t.Setenv("PORTAL_TEST_TOKEN", "secret-value") + + var seen http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + NewProber(2*time.Second).Probe(context.Background(), Environment{ + URL: server.URL, + Headers: map[string]string{"X-Api-Version": "2026-01-01"}, + HeadersFromEnv: map[string]string{ + "X-Token": "PORTAL_TEST_TOKEN", + "X-Missing": "PORTAL_TEST_UNSET", + }, + }) + + if got := seen.Get("X-Api-Version"); got != "2026-01-01" { + t.Errorf("literal header = %q, want %q", got, "2026-01-01") + } + if got := seen.Get("X-Token"); got != "secret-value" { + t.Errorf("env header = %q, want %q", got, "secret-value") + } + // An unset variable must not become an empty header: an empty credential + // reads as a malformed request rather than an anonymous one. + if _, present := seen["X-Missing"]; present { + t.Error("unset variable produced a header; want it skipped") + } +} + +// Headers belong to the environment that declared them. A catalogue with a +// token on prod must not leak it to the localhost entry beside it. +func TestProbeDoesNotShareHeadersBetweenEnvironments(t *testing.T) { + t.Setenv("PORTAL_TEST_TOKEN", "secret-value") + + var seen http.Header + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + seen = r.Header.Clone() + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + prober := NewProber(2 * time.Second) + prober.Probe(context.Background(), Environment{ + URL: server.URL, + HeadersFromEnv: map[string]string{"X-Token": "PORTAL_TEST_TOKEN"}, + }) + prober.Probe(context.Background(), Environment{URL: server.URL}) + + if got := seen.Get("X-Token"); got != "" { + t.Errorf("second environment received %q; headers must not carry over", got) + } +} diff --git a/src/internal/portal/probe.go b/src/internal/portal/probe.go index 3d816c2..5bdd478 100644 --- a/src/internal/portal/probe.go +++ b/src/internal/portal/probe.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/http" + "os" "strings" "sync" "time" @@ -86,6 +87,17 @@ func (p *Prober) Probe(ctx context.Context, env Environment) Status { return Status{State: StateDown, Detail: "bad url"} } request.Header.Set("accept", "*/*") + for name, value := range env.Headers { + request.Header.Set(name, value) + } + // Read at probe time, by the name the catalogue asked for. The portal never + // goes looking for credentials on its own — it forwards exactly what the + // project declared, and only to the environment that declared it. + for name, variable := range env.HeadersFromEnv { + if value := os.Getenv(variable); value != "" { + request.Header.Set(name, value) + } + } response, err := p.Client.Do(request) elapsed := time.Since(started) diff --git a/src/prelude/options/portal.nix b/src/prelude/options/portal.nix index 8bd9220..7900a74 100644 --- a/src/prelude/options/portal.nix +++ b/src/prelude/options/portal.nix @@ -20,6 +20,39 @@ example = "http://127.0.0.1:1338/health"; }; + headers = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = {}; + description = '' + Extra headers sent with the probe. For non-secret values only — an + API version, a tenant id, a header a staging proxy expects. This + catalogue is generated into the store and lives in a git repo, so a + credential here is a credential in the repo; use `headersFromEnv`. + ''; + example = lib.literalExpression '' + { "X-Api-Version" = "2026-01-01"; } + ''; + }; + + headersFromEnv = lib.mkOption { + type = lib.types.attrsOf lib.types.str; + default = {}; + description = '' + Headers whose values are read from the environment at probe time, + mapping header name to variable name. Lets a gated environment be + probed for real health rather than reported as `gated`, without the + portal knowing anything about your auth provider or going looking for + credentials on its own. A variable that is unset is skipped, so an + unconfigured shell simply probes anonymously. + ''; + example = lib.literalExpression '' + { + "CF-Access-Client-Id" = "CLOUDFLARE_ACCESS_CLIENT_ID"; + "CF-Access-Client-Secret" = "CLOUDFLARE_ACCESS_CLIENT_SECRET"; + } + ''; + }; + gated = lib.mkOption { type = lib.types.bool; default = false; diff --git a/src/prelude/portal.nix b/src/prelude/portal.nix index c34f5b6..4617866 100644 --- a/src/prelude/portal.nix +++ b/src/prelude/portal.nix @@ -49,6 +49,8 @@ config: let url = entry.value.url; health = entry.value.health; gated = entry.value.gated; + headers = entry.value.headers; + headersFromEnv = entry.value.headersFromEnv; }) (lib.attrsToList app.environments); configFile = writeText "prelude-portal.json" (