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:
- Local dev — a contributor runs
cargo run -p cli -- dev upagainst KIND with an empty.envand gets today's exact behavior. - GCP dev — the same operator runs
navigator ops gcp setup/navigator dev deployagainst a real project, values from.env. - OSS / multi-cloud forks — a fork plugs its own cluster, namespace, overlay, and ports into
.envand runs the samenavigatorCLI with no Rust edits, mirroring theshippromise 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:
- Shared concepts get one var. A Kubernetes namespace is the same idea in KIND and GKE, so it is
NAVIGATOR_K8S_NAMESPACE(noKIND/GKEprefix). - Provider-specific concepts fork by scope. The cluster name differs by provider — prod already has
NAVIGATOR_GKE_CLUSTER_NAME, so the KIND cluster isNAVIGATOR_KIND_CLUSTER. - Overlay paths generalize.
NAVIGATOR_KIND_OVERLAY(full local stack) andNAVIGATOR_GKE_OVERLAYare the same idea at two scopes; a fork points either at its own kustomize overlay.
Host ports
The host ports split into two categories with very different blast radius:
- Pure port-forward ports — Postgres (15432), Restate ingress (9080), Restate admin (9070), OPA (8181), Garage S3
(30900), and the local web port (3001). These are host-side
kubectl port-forward LOCAL:REMOTEchoices. - Create-time NodePort mapping — Keycloak (30080) reaches the host through
k8s/kind-config.yaml.
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:
- Cluster + namespace. Introduce
KindConfig+from_env()+ theDEFAULT_*constants; thread&KindConfigthrough the subcommands. Onlyclusterandnamespaceread env here; the threading is the bulk of the work and happens once, so later slices are additive field reads. - Overlay paths. Add the three overlay fields.
- Host ports +
kind-config.yamltemplating. Add the seven port fields; renderk8s/kind-config.yamlto 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.
Related
RUNBOOK.md— the dev loop this extends.cloud-operations.md+.env.example— the env-driven prod surface this stays consistent with.oss-install.md—navigator ops gcp setupenv conventions.