Env-driven orchestration — one config surface, three audiences

NAVIGATOR_ENVIRONMENT is the one deployment-profile selector shared by web, workflows-service, and production shipping preflight. Exact staging selects staging; empty or unset selects production; every other nonempty value is an error. It does not select database engines, application features, or authorization behavior.

A deployment operator owns Kubernetes, cloud accounts, secrets, domains, and these environment values. That person is distinct from a Navigator application admin, whose database role grants application authorization but no infrastructure access.

The orchestration is part of the navigator CLI (the cli crate, cli::devx module in cli/src/devx/mod.rs). Its production GCP path already reads configuration from NAVIGATOR_* environment variables via clap; its KIND/local path historically used hard-coded module constants. This document records the design that brings the KIND path onto the same env-driven surface, so one tool serves three audiences from one config file:

  1. Local dev — a contributor runs cargo run -p cli -- dev up against KIND with an empty .env and gets today's exact behavior.
  2. GCP dev — the same operator runs navigator ops gcp setup / navigator dev deploy against a real project, values from .env.
  3. OSS / multi-cloud forks — a fork plugs its own cluster, namespace, overlay, and ports into .env and runs the same navigator CLI with no Rust edits, mirroring the ship promise that "nothing is hard-coded."

The Council of Twelve review shaped this design; its findings are folded in below.

The seam: one KindConfig, resolved once

All KIND/local knobs collapse into a single KindConfig struct, resolved once in main() from the environment and threaded into the subcommands that need it. Each field falls back to a DEFAULT_* constant — the same value the old inline const held — so an empty .env reproduces prior behavior exactly.

struct KindConfig {
    cluster: String,             // NAVIGATOR_KIND_CLUSTER       default "navigator"
    namespace: String,           // NAVIGATOR_K8S_NAMESPACE      default "navigator"
    deps_overlay: String,        // NAVIGATOR_KIND_DEPS_OVERLAY  default "k8s/overlays/kind-deps"
    full_overlay: String,        // NAVIGATOR_KIND_OVERLAY       default "k8s/overlays/kind"
    gke_overlay: String,         // NAVIGATOR_GKE_OVERLAY        default "examples/deploy/k8s/gke"
    postgres_port: u16,          // NAVIGATOR_KIND_POSTGRES_PORT default 15432
    restate_ingress_port: u16,   // NAVIGATOR_KIND_RESTATE_INGRESS_PORT default 9080
    restate_admin_port: u16,     // NAVIGATOR_KIND_RESTATE_ADMIN_PORT  default 9070
    opa_port: u16,               // NAVIGATOR_KIND_OPA_PORT      default 8181
    keycloak_port: u16,          // NAVIGATOR_KIND_KEYCLOAK_PORT default 30080
    garage_s3_port: u16,        // NAVIGATOR_KIND_GARAGE_S3_PORT default 30900
    web_port: u16,               // NAVIGATOR_KIND_WEB_PORT      default 3001
}

Why a struct threaded once, not env::var at each call site: the constants were read at 20+ call sites across up, deploy, down, status, render_env, and the cluster-lifecycle helpers. Scattering env::var would re-fragment the config and make the next knob land inconsistently. One from_env() is the single place a reader looks, and the single place a new knob is added.

Naming: role, not provider

Variables are named NAVIGATOR_<scope>_<thing> so .env.example reads as one coherent table rather than two dialects:

Host ports

The host ports split into two categories with very different blast radius:

To make the Keycloak host port overridable without breaking a standalone kind create cluster, the CLI renders k8s/kind-config.yaml to a temporary file and substitutes only that hostPort: value. Garage needs no cluster recreation when its host port changes because dev up owns its port-forward.

Implementation sequence

Three commits, smallest-first, so the risk-bearing YAML work lands last:

  1. Cluster + namespace. Introduce KindConfig + from_env() + the DEFAULT_* constants; thread &KindConfig through the subcommands. Only cluster and namespace read env here; the threading is the bulk of the work and happens once, so later slices are additive field reads.
  2. Overlay paths. Add the three overlay fields.
  3. Host ports + kind-config.yaml templating. Add the seven port fields; render k8s/kind-config.yaml to a temp file for Keycloak.

Testing

The orchestration had no tests before this work. The load-bearing test is "no env set → KindConfig::from_env() equals the old constants exactly" — the safety net for the whole change. Each slice adds default-vs-override coverage for its new fields, plus a render_env test (ports thread into the generated .devx/env) and a kind-config.yaml render test (default ports → byte-identical output; overridden ports → only hostPort lines change). Tests land in the same commit as the code they cover, per workspace TDD discipline.