diff --git a/AGENTS.md b/AGENTS.md index e3e9843..8a59ac0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -172,6 +172,14 @@ High-level layout; see the Child NAD Index below for domain-specific details. config used by background tasks (e.g. `SMTP_*`) must be passed to `celery-worker` in `compose.yml`, not only to `backend` — otherwise it fails silently in prod while request-path features (like the SMTP test) still work. +- **`.env` and `.env.development` drift out of date** as `.env.example` evolves. + Run `./nukelabctl check-env` to detect missing or stale keys, and + `./nukelabctl sync-env` to non-destructively append missing keys from + `.env.example` while preserving local values and secrets. +- **Production deployments must be version-pinned**. When `APP_ENV=production`, + `nukelabctl` refuses to boot if the resolved version is `0.0.0-dev`. Pin a + release with a `VERSION` file, a git tag, or an explicit `NUKELAB_VERSION` + / `NUKELAB_IMAGE_TAG` (use the latter when pulling pre-built GHCR images). ## Security & penetration testing diff --git a/CHANGELOG.md b/CHANGELOG.md index e98ff42..02c135c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,18 @@ release version. `DB_SCHEMA_GUARD` setting (`auto` refuse in production/warn elsewhere, `enforce` always refuse, `off` disabled). If the database is unreachable the guard logs a warning and does not block startup. +- Environment-file drift detection and repair: `./nukelabctl check-env` compares + `.env` / `.env.development` against `.env.example` and reports missing or + stale keys (optionally value differences with `--changed`, all files with + `--all`). `./nukelabctl sync-env` non-destructively appends missing keys from + `.env.example` while preserving existing local values and secrets; stale keys + are reported but left for manual removal. Both commands are read-only by + default unless `sync-env` is invoked with `--yes` or confirmed interactively. +- Production version guard: when `APP_ENV=production`, `nukelabctl` refuses to + boot if the resolved version is `0.0.0-dev`. Production requires a real + release via `VERSION`, a git tag, or an explicit `NUKELAB_VERSION` / + `NUKELAB_IMAGE_TAG` (the latter is useful when pulling pre-built GHCR + images). ### Changed diff --git a/nukelabctl b/nukelabctl index 829787a..7bf4ac9 100755 --- a/nukelabctl +++ b/nukelabctl @@ -248,6 +248,10 @@ ${BOLD}Maintenance:${RESET} ${GREEN}rotate-user-auth-key${RESET} Rotate the active user-auth Ed25519 key ${GREEN}cleanup-user-auth-keys${RESET} Remove expired retired user-auth keys +${BOLD}Environment:${RESET} + ${GREEN}check-env${RESET} [options] Compare env files to .env.example + ${GREEN}sync-env${RESET} [file] [options] Merge missing keys from .env.example + ${BOLD}Development Tools:${RESET} ${GREEN}shell${RESET} [service] Open shell in container ${GREEN}exec${RESET} [service] [command] Execute command in container @@ -320,6 +324,9 @@ ${BOLD}Examples:${RESET} ./nukelabctl init-user-auth-keys # Generate initial user-auth keys (production setup) ./nukelabctl rotate-user-auth-key # Rotate the active user-auth key ./nukelabctl cleanup-user-auth-keys # Prune expired retired public keys + ./nukelabctl check-env # Check active env file for drift + ./nukelabctl check-env --all --changed # Check all env files including value drift + ./nukelabctl sync-env .env.development --dry-run # Preview missing keys EOF } @@ -465,6 +472,10 @@ main() { lint) _dispatch_command "$CMD" ;; + check-env | sync-env) + # These commands only parse env files; no container engine or env state needed. + _dispatch_command "$CMD" + ;; status | logs | shell | exec | db-migrate | db-shell | backup | restore | e2e | security | doctor | init-user-auth-keys | rotate-user-auth-key | cleanup-user-auth-keys | verify-hardening) _bootstrap restore _dispatch_command "$CMD" diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 82d2c47..893c145 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -18,6 +18,13 @@ All files under `scripts/`, plus the top-level `nukelabctl` dispatcher. - `update` has a `--build` escape hatch that forces a source rebuild even when `NUKELAB_PULL_DEPLOY=true`. In source-build mode `update` and `pull` pull base images for the pullable infra services only, via `_pullable_infra_services`. - `_pullable_infra_services` returns the pullable (non-buildable) infra services (`traefik postgres redis` + enabled overlay services) as a word-split list, mirroring `_backend_services`. App services (`backend`, `celery-worker`, `celery-beat`, `frontend`) are excluded so unpinned ghcr.io images are never pulled without registry auth; pull mode pulls them explicitly. - Each management command exposes `cmd_`, `help_`, and `parse__args` when it accepts flags. +- Environment-file helpers live in `scripts/lib.sh`: + - `load_env_file` exports active KEY=VALUE lines from a file. + - `_read_env_into_assoc` reads active KEY=VALUE lines into an associative array without exporting. + - `_assoc_has_key` tests associative-array key membership. +- `check-env` and `sync-env` compare local env files against `.env.example`. + `check-env` is read-only; `sync-env` appends missing keys without overwriting + existing values and never removes stale keys without operator consent. - Security scanning helpers live in `scripts/security/`. ## Work Guidance diff --git a/scripts/lib.sh b/scripts/lib.sh index 63c0171..d2a581c 100755 --- a/scripts/lib.sh +++ b/scripts/lib.sh @@ -157,6 +157,47 @@ load_env_file() { done < "$env_file" } +# Usage: _read_env_into_assoc +# Reads active KEY=VALUE lines from into the named associative array. +# Comments, blank lines, and malformed keys are ignored. An optional leading +# `export ` prefix is tolerated, and trailing inline comments are stripped +# (only when # is preceded by whitespace, matching load_env_file). +# The array is reset before populating; values are NOT exported. +_read_env_into_assoc() { + local env_file="$1" + local -n _assoc="$2" + _assoc=() + while IFS= read -r line || [[ -n "$line" ]]; do + [[ "$line" =~ ^[[:space:]]*#.*$ ]] && continue + [[ -z "${line// /}" ]] && continue + + local cleaned="${line#export }" + + if [[ "$cleaned" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)[[:space:]]+#.*$ ]]; then + cleaned="${BASH_REMATCH[1]}=${BASH_REMATCH[2]}" + while [[ "$cleaned" == *[[:space:]] ]]; do + cleaned="${cleaned%[[:space:]]}" + done + fi + + local key="${cleaned%%=*}" + if [[ "$cleaned" != *=* ]] || [[ ! "$key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then + continue + fi + + local value="${cleaned#*=}" + _assoc["$key"]="$value" + done < "$env_file" +} + +# Usage: _assoc_has_key +# Returns 0 if the associative array contains the given key, 1 otherwise. +_assoc_has_key() { + local -n _assoc="$1" + local key="$2" + [[ -v "_assoc[$key]" ]] +} + # Usage: init_env [dev_mode] # In dev mode (.env.development present) the dev file is loaded FIRST so its # values win: load_env_file never overwrites an already-set variable, so .env @@ -210,6 +251,18 @@ init_env() { export NUKELAB_VERSION="${_nv#v}" fi + # Production guard: refuse to operate with the unresolved development + # fallback version. A production stack must be pinned to a real release + # via VERSION, a git tag, or an explicit NUKELAB_VERSION / NUKELAB_IMAGE_TAG. + if [ "${APP_ENV:-}" = "production" ] && [ "$NUKELAB_VERSION" = "0.0.0-dev" ]; then + die "Production deployments require a release version, but the resolved version is 0.0.0-dev.\n\n\ +Resolve this by one of:\n\ + - Create a VERSION file: echo '2.0.0' > VERSION\n\ + - Check out a git tag (vX.Y.Z)\n\ + - Set NUKELAB_VERSION explicitly in the environment or env file\n\ + - Set NUKELAB_IMAGE_TAG explicitly when pulling pre-built images" + fi + # Default the registry image tag consumed by compose.yml image: # substitutions. When the operator explicitly pinned NUKELAB_VERSION, use # it as the image tag unless NUKELAB_IMAGE_TAG was pinned separately. @@ -1334,7 +1387,7 @@ _ensure_venv_tool() { # Resolve the NukeLab version string. Preference order: # 1. $DIR/VERSION file (publishable artifact) # 2. git describe --tags (e.g. v2.0, v2.0-3-gabc123) -# 3. hardcoded default (kept as a last-resort fallback) +# 3. hardcoded development fallback (must never look like a release) # # Lives in lib.sh (not scripts/manage.d/version.sh) because print_help() in # nukelabctl calls it before any command module has been sourced. @@ -1350,9 +1403,10 @@ _nukelab_version() { if command -v git > /dev/null 2>&1 && [ -d "$DIR/.git" ]; then # --tags only succeeds when at least one tag exists; --always is # intentionally omitted so a bare short-sha never masks the - # hardcoded fallback default. The trailing `|| true` plus the `if` + # development fallback. The trailing `|| true` plus the `if` # guard both neutralize the ERR trap inherited via `set -E` so a - # tag-less repo falls through to the v2.0 default instead of aborting. + # tag-less repo falls through to the 0.0.0-dev fallback instead of + # aborting. if version=$(cd "$DIR" && git describe --tags 2> /dev/null || true); then if [ -n "$version" ]; then echo "$version" @@ -1360,5 +1414,5 @@ _nukelab_version() { fi fi fi - echo "v2.0" + echo "0.0.0-dev" } diff --git a/scripts/manage.d/check-env.sh b/scripts/manage.d/check-env.sh new file mode 100755 index 0000000..8e4d949 --- /dev/null +++ b/scripts/manage.d/check-env.sh @@ -0,0 +1,127 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2023-2026 NukeHub Developers +# SPDX-License-Identifier: BSD-2-Clause + +# Default options for check-env. +CHECK_ENV_ALL=false +CHECK_ENV_CHANGED=false + +cmd_check_env() { + local target_files=() + + if $CHECK_ENV_ALL; then + [ -f .env ] && target_files+=(".env") + [ -f .env.development ] && target_files+=(".env.development") + else + if $USE_DEV_MODE && [ -f .env.development ]; then + target_files+=(".env.development") + elif [ -f .env ]; then + target_files+=(".env") + elif [ -f .env.development ]; then + target_files+=(".env.development") + else + die "No environment file found.\n\n cp .env.example .env.development" + fi + fi + + [ -f .env.example ] || die ".env.example not found" + + declare -A example_values + _read_env_into_assoc .env.example example_values + + local exit_code=0 + for target in "${target_files[@]}"; do + step "Checking ${target} against .env.example..." + + declare -A target_values + _read_env_into_assoc "$target" target_values + + local missing=() stale=() changed=() + + for key in "${!example_values[@]}"; do + if ! _assoc_has_key target_values "$key"; then + missing+=("$key") + fi + done + + for key in "${!target_values[@]}"; do + if ! _assoc_has_key example_values "$key"; then + stale+=("$key") + fi + done + + if $CHECK_ENV_CHANGED; then + for key in "${!target_values[@]}"; do + if _assoc_has_key example_values "$key"; then + if [ "${target_values[$key]}" != "${example_values[$key]}" ]; then + changed+=("$key") + fi + fi + done + fi + + if [ ${#missing[@]} -eq 0 ] && [ ${#stale[@]} -eq 0 ] && [ ${#changed[@]} -eq 0 ]; then + ok "${target} is in sync with .env.example" + else + exit_code=1 + if [ ${#missing[@]} -gt 0 ]; then + echo "${RED}✗${RESET} Missing keys in ${target}:" + printf ' - %s\n' "${missing[@]}" | sort + fi + if [ ${#stale[@]} -gt 0 ]; then + echo "${YELLOW}⚠${RESET} Stale keys in ${target} (removed from .env.example):" + printf ' - %s\n' "${stale[@]}" | sort + fi + if [ ${#changed[@]} -gt 0 ]; then + echo "${BLUE}▶${RESET} Changed values in ${target}:" + printf ' - %s\n' "${changed[@]}" | sort + fi + fi + done + + exit $exit_code +} + +parse_check_env_args() { + while [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; do + case "${EXTRA_ARGS[0]}" in + --all) + CHECK_ENV_ALL=true + EXTRA_ARGS=("${EXTRA_ARGS[@]:1}") + ;; + --changed) + CHECK_ENV_CHANGED=true + EXTRA_ARGS=("${EXTRA_ARGS[@]:1}") + ;; + --help | -h) + help_check_env + exit 0 + ;; + --*) + die "Unknown option for check-env: ${EXTRA_ARGS[0]}" + ;; + *) + die "Unexpected argument for check-env: ${EXTRA_ARGS[0]}" + ;; + esac + done +} + +help_check_env() { + cat <<- EOF +${BOLD}Usage:${RESET} ./nukelabctl check-env [options] + +Compare .env / .env.development against the canonical .env.example template. +Reports missing keys, stale keys, and (with --changed) keys whose values differ +from the example defaults. + +${BOLD}Options:${RESET} + --all Check both .env and .env.development if they exist + --changed Also report keys whose local value differs from .env.example + --help, -h Show this help + +${BOLD}Examples:${RESET} + ./nukelabctl check-env + ./nukelabctl check-env --all --changed +EOF +} diff --git a/scripts/manage.d/sync-env.sh b/scripts/manage.d/sync-env.sh new file mode 100755 index 0000000..c1294ea --- /dev/null +++ b/scripts/manage.d/sync-env.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# SPDX-FileCopyrightText: 2023-2026 NukeHub Developers +# SPDX-License-Identifier: BSD-2-Clause + +# Default options for sync-env. +SYNC_ENV_DRY_RUN=false +SYNC_ENV_YES=false +SYNC_ENV_TARGET="" + +cmd_sync_env() { + local target="${SYNC_ENV_TARGET}" + + if [ -z "$target" ]; then + if $USE_DEV_MODE && [ -f .env.development ]; then + target=".env.development" + elif [ -f .env ]; then + target=".env" + elif [ -f .env.development ]; then + target=".env.development" + else + die "No environment file found.\n\n cp .env.example .env.development" + fi + fi + + [ -f .env.example ] || die ".env.example not found" + [ "$target" = ".env.example" ] && die "Cannot sync .env.example into itself" + + declare -A example_values + _read_env_into_assoc .env.example example_values + + declare -A target_values + _read_env_into_assoc "$target" target_values + + local to_add=() + for key in "${!example_values[@]}"; do + if ! _assoc_has_key target_values "$key"; then + to_add+=("$key") + fi + done + + local stale=() + for key in "${!target_values[@]}"; do + if ! _assoc_has_key example_values "$key"; then + stale+=("$key") + fi + done + + if [ ${#to_add[@]} -eq 0 ] && [ ${#stale[@]} -eq 0 ]; then + ok "${target} is already in sync with .env.example" + return 0 + fi + + if [ ${#to_add[@]} -gt 0 ]; then + echo "${BLUE}▶${RESET} Keys to add to ${target}:" + printf ' + %s\n' "${to_add[@]}" | sort + fi + if [ ${#stale[@]} -gt 0 ]; then + echo "${YELLOW}⚠${RESET} Stale keys in ${target} (not removed, delete manually if desired):" + printf ' - %s\n' "${stale[@]}" | sort + fi + + if $SYNC_ENV_DRY_RUN; then + echo "${BLUE}▶${RESET} Dry run: no changes written" + return 0 + fi + + if ! $SYNC_ENV_YES; then + local reply + read -r -p "Append missing keys to ${target}? [y/N] " reply + if [[ ! "$reply" =~ ^[Yy]$ ]]; then + echo "${BLUE}▶${RESET} Aborted" + return 0 + fi + fi + + { + echo "" + echo "# =============================================================================" + echo "# SYNCED FROM .env.example — $(date -Iseconds)" + echo "# =============================================================================" + for key in "${to_add[@]}"; do + echo "${key}=${example_values[$key]}" + done + } >> "$target" + + ok "Appended ${#to_add[@]} missing key(s) to ${target}" +} + +parse_sync_env_args() { + while [[ ${#EXTRA_ARGS[@]} -gt 0 ]]; do + case "${EXTRA_ARGS[0]}" in + --dry-run) + SYNC_ENV_DRY_RUN=true + EXTRA_ARGS=("${EXTRA_ARGS[@]:1}") + ;; + --yes | -y) + SYNC_ENV_YES=true + EXTRA_ARGS=("${EXTRA_ARGS[@]:1}") + ;; + --help | -h) + help_sync_env + exit 0 + ;; + --*) + die "Unknown option for sync-env: ${EXTRA_ARGS[0]}" + ;; + *) + if [ -z "${SYNC_ENV_TARGET}" ]; then + SYNC_ENV_TARGET="${EXTRA_ARGS[0]}" + EXTRA_ARGS=("${EXTRA_ARGS[@]:1}") + else + die "Unexpected argument for sync-env: ${EXTRA_ARGS[0]}" + fi + ;; + esac + done +} + +help_sync_env() { + cat <<- EOF +${BOLD}Usage:${RESET} ./nukelabctl sync-env [file] [options] + +Non-destructively merge missing keys from .env.example into a local env file. +Existing keys and their values are never overwritten, so secrets and local +overrides stay intact. Stale keys are reported but left in place. + +${BOLD}Arguments:${RESET} + file Target env file (default: active env file) + +${BOLD}Options:${RESET} + --dry-run Show what would be added without writing anything + --yes, -y Skip the confirmation prompt + --help, -h Show this help + +${BOLD}Examples:${RESET} + ./nukelabctl sync-env + ./nukelabctl sync-env .env.development --dry-run + ./nukelabctl sync-env .env --yes +EOF +} diff --git a/scripts/nukelabctl-completion.bash b/scripts/nukelabctl-completion.bash index 3b17e5f..a72476b 100644 --- a/scripts/nukelabctl-completion.bash +++ b/scripts/nukelabctl-completion.bash @@ -12,7 +12,7 @@ _manage_sh_complete() { shell exec install test e2e loadtest db-migrate db-shell backup restore reset dev lint security cache-toolchain init-user-auth-keys rotate-user-auth-key cleanup-user-auth-keys - doctor version install-completion selftest help + check-env sync-env doctor version install-completion selftest help ) local global_flags=(--coverage --overlay -o --verbose -v --quiet -q --skip-port-check --no-alertmanager --version --help -h) @@ -128,6 +128,17 @@ _manage_sh_complete() { db-migrate) COMPREPLY=($(compgen -W "--no-backup ${global_flags[*]}" -- "$cur")) ;; + check-env) + COMPREPLY=($(compgen -W "--all --changed ${global_flags[*]}" -- "$cur")) + ;; + sync-env) + # First positional is the env file; after that only flags. + if [[ "$cur" == -* ]]; then + COMPREPLY=($(compgen -W "--dry-run --yes -y ${global_flags[*]}" -- "$cur")) + else + COMPREPLY=($(compgen -f -- "$cur")) + fi + ;; *) COMPREPLY=($(compgen -W "${global_flags[*]}" -- "$cur")) ;;