diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example index 0b98c6c..1550b68 100644 --- a/.devcontainer/.env.example +++ b/.devcontainer/.env.example @@ -1,26 +1,46 @@ # ============================================================ -# Musher Dev Container Environment Configuration +# Dev Container Environment Template # ============================================================ -# Copy this file to .env and customize as needed: -# cp .env.example .env +# This file is the source of truth for local-dev environment vars. +# On first container build, `initializeCommand` copies it to +# `.devcontainer/.env` (gitignored) on the host. From there: +# +# * Docker Compose auto-discovers it (sibling to compose.yaml) +# and interpolates ${VAR:-default} references. +# * `runArgs --env-file` loads it into the dev container itself +# so shells, runtimes, and `task` runs see the same values. +# +# Drift checks: `task env:check` compares this file against your +# local `.env` and flags missing keys. +# +# Convention (three states): +# 1. Filled defaults — `VAR=value` safe demo values; override only if needed. +# 2. Required (empty) — `VAR=` must be filled in; container warns at startup. +# 3. Optional overrides — `# VAR=value` uncomment to enable. # ============================================================ -# --- Service Profiles --- + +# === Service Profiles ======================================= # Comma-separated list of optional services to enable. # Available: redis, minio, registry, azimutt, observability COMPOSE_PROFILES= -# --- PostgreSQL --- + +# === PostgreSQL ============================================= POSTGRES_USER=postgres POSTGRES_PASSWORD=postgres POSTGRES_DB=app -# --- MinIO (project-level S3 storage) --- + +# === MinIO (project S3 storage) ============================= MINIO_ROOT_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin -# --- Observability MinIO --- -# NOTE: These must also match values in config/observability/tempo-config.yaml -# and config/observability/loki-config.yaml (those files cannot use env interpolation) + +# === MinIO (observability stack) ============================ +# NOTE: these credentials must also match the values hardcoded in +# config/observability/tempo-config.yaml +# config/observability/loki-config.yaml +# (those native YAML configs don't support env interpolation). MINIO_OBS_ROOT_USER=minioadmin MINIO_OBS_ROOT_PASSWORD=minioadmin diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 2f9aff1..65ad786 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -70,6 +70,11 @@ // Allow debuggers (gdb, strace, delve) to attach to processes. "capAdd": ["SYS_PTRACE"], + // Load .devcontainer/.env into the container at `docker run` time so + // processes inside the container (shells, runtimes, task runs) see the + // same vars Docker Compose interpolates into service configs. + "runArgs": ["--env-file", "${localWorkspaceFolder}/.devcontainer/.env"], + "mounts": [ "source=musher-${devcontainerId}-gh-config,target=/home/vscode/.config/gh,type=volume", "source=musher-${devcontainerId}-claude-config,target=/home/vscode/.claude,type=volume", @@ -100,6 +105,11 @@ // "GOROOT": "" }, + // Host-side bootstrap: creates .devcontainer/.env from the template + // (if missing) and strips CRLF, so --env-file above has a valid target + // even on a fresh clone. + "initializeCommand": ["bash", ".devcontainer/scripts/initialize.sh"], + "waitFor": "postCreateCommand", "postCreateCommand": { "fix-crlf": "find .devcontainer/scripts -type f -name '*.sh' -exec sed -i 's/\\r$//' {} +", diff --git a/.devcontainer/scripts/initialize.sh b/.devcontainer/scripts/initialize.sh new file mode 100644 index 0000000..2b4b333 --- /dev/null +++ b/.devcontainer/scripts/initialize.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# initialize.sh — Host-side bootstrap for the dev container. +# +# Runs on the host (via devcontainer.json `initializeCommand`) BEFORE +# `docker run` is invoked. Because `runArgs --env-file` is evaluated at +# `docker run` time, the .env file must exist on the host before the +# container starts — that's why this work lives here, not in +# post-create.sh. +# +# Responsibilities: +# * Create .devcontainer/.env from .env.example on first clone. +# * Touch an empty .env if no example exists, so --env-file never hard-fails. +# * Strip CRLF from .env (Windows/WSL safety — docker --env-file +# rejects files with CRLF line endings). +# +# Idempotent: safe to run on every container start. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly SCRIPT_DIR +readonly DEVCONTAINER_DIR="${SCRIPT_DIR}/.." +readonly ENV_FILE="${DEVCONTAINER_DIR}/.env" +readonly ENV_EXAMPLE="${DEVCONTAINER_DIR}/.env.example" + +log() { + echo "[initialize] $*" >&2 +} + +ensure_env_file() { + if [[ -f "${ENV_FILE}" ]]; then + return 0 + fi + if [[ -f "${ENV_EXAMPLE}" ]]; then + log "Creating .devcontainer/.env from .env.example" + cp "${ENV_EXAMPLE}" "${ENV_FILE}" + else + log "No .env.example found; creating empty .devcontainer/.env" + : > "${ENV_FILE}" + fi +} + +strip_crlf() { + [[ -f "${ENV_FILE}" ]] || return 0 + if grep -q $'\r' "${ENV_FILE}" 2>/dev/null; then + log "Stripping CRLF from .devcontainer/.env" + sed -i 's/\r$//' "${ENV_FILE}" + fi +} + +main() { + ensure_env_file + strip_crlf +} + +main "$@" diff --git a/.devcontainer/scripts/lib/env-check.sh b/.devcontainer/scripts/lib/env-check.sh new file mode 100644 index 0000000..6879c73 --- /dev/null +++ b/.devcontainer/scripts/lib/env-check.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# env-check.sh — Library: compare a local .env against its template. +# +# This is a library file meant to be sourced, not executed directly. +# Usage: source "path/to/env-check.sh" +# +# Exposes: +# env_check_keys — print KEY names (one per line) to stdout +# env_check_drift — print missing keys; non-zero if any drift +# env_check_required — print keys with empty values to stdout +set -euo pipefail + +# Extracts variable names from a dotenv-style file. +# +# Recognizes lines that start with an uppercase identifier followed by '='. +# Skips blanks, comments (`#`), and commented-out overrides. +# +# Arguments: +# $1 — path to env file +# Outputs: +# One KEY per line on stdout (deduplicated, in file order) +env_check_keys() { + local file="${1:?usage: env_check_keys }" + [[ -f "${file}" ]] || return 0 + grep -E '^[A-Z_][A-Z0-9_]*=' "${file}" | sed 's/=.*//' | awk '!seen[$0]++' +} + +# Reports keys present in but missing from . +# +# Arguments: +# $1 — path to .env (the user's local file) +# $2 — path to .env.example (the template) +# Outputs: +# Missing keys, one per line, to stderr +# Returns: +# 0 if no drift, 1 if any keys are missing +env_check_drift() { + local env_file="${1:?usage: env_check_drift }" + local example_file="${2:?}" + [[ -f "${example_file}" ]] || return 0 + + local expected actual missing + expected="$(env_check_keys "${example_file}" | sort -u)" + actual="$(env_check_keys "${env_file}" | sort -u)" + missing="$(comm -23 <(echo "${expected}") <(echo "${actual}"))" + + if [[ -n "${missing}" ]]; then + echo "${missing}" >&2 + return 1 + fi + return 0 +} + +# Reports keys present in whose value is empty (the "required, please +# fill in" state from the three-state grammar). +# +# Arguments: +# $1 — path to .env +# Outputs: +# Empty-valued keys, one per line, to stdout +# Returns: +# 0 always (informational) +env_check_required() { + local env_file="${1:?usage: env_check_required }" + [[ -f "${env_file}" ]] || return 0 + grep -E '^[A-Z_][A-Z0-9_]*=$' "${env_file}" | sed 's/=$//' || true +} diff --git a/.devcontainer/scripts/lib/motd.sh b/.devcontainer/scripts/lib/motd.sh index 307b820..3880948 100644 --- a/.devcontainer/scripts/lib/motd.sh +++ b/.devcontainer/scripts/lib/motd.sh @@ -142,6 +142,50 @@ _motd_aliases() { echo " claude Claude Code AI" } +# Warns when .env is missing keys from .env.example, or has empty +# required values. Silent when env is healthy. +# +# Globals: +# _BOLD, _DIM, _YELLOW, _RESET — color codes set by _motd_setup_colors +# Arguments: +# $1 — .devcontainer directory (where .env / .env.example live) +_motd_env_warnings() { + local devcontainer_dir="${1:-}" + [[ -d "${devcontainer_dir}" ]] || return 0 + + local env_file="${devcontainer_dir}/.env" + local example_file="${devcontainer_dir}/.env.example" + local lib_file="${devcontainer_dir}/scripts/lib/env-check.sh" + [[ -f "${lib_file}" ]] || return 0 + + # shellcheck source=./env-check.sh + source "${lib_file}" + + local missing="" required="" + if [[ -f "${example_file}" ]]; then + missing="$(env_check_drift "${env_file}" "${example_file}" 2>&1 || true)" + fi + required="$(env_check_required "${env_file}" 2>/dev/null || true)" + + if [[ -z "${missing}" && -z "${required}" ]]; then + return 0 + fi + + local sep + sep="$(printf '─%.0s' {1..54})" + echo "" + echo " ${_BOLD}${_YELLOW}Environment${_RESET}" + echo " ${_DIM}${sep}${_RESET}" + if [[ -n "${missing}" ]]; then + echo " ${_YELLOW}Missing keys in .env (run 'task env:reset' to sync):${_RESET}" + awk '{print " - " $0}' <<< "${missing}" + fi + if [[ -n "${required}" ]]; then + echo " ${_YELLOW}Required keys with empty values:${_RESET}" + awk '{print " - " $0}' <<< "${required}" + fi +} + _motd_tips() { local sep sep="$(printf '─%.0s' {1..54})" @@ -159,10 +203,12 @@ _motd_tips() { # # Arguments: # $1 — path to compose.yaml (may be empty to skip services) +# $2 — path to .devcontainer/ directory (may be empty to skip env warnings) # Outputs: # MOTD text to stdout show_motd() { local compose_file="${1:-}" + local devcontainer_dir="${2:-}" _motd_setup_colors local border @@ -172,6 +218,7 @@ show_motd() { _motd_header _motd_runtimes _motd_services "$compose_file" + _motd_env_warnings "$devcontainer_dir" _motd_aliases _motd_tips echo "${_BOLD}${border}${_RESET}" diff --git a/.devcontainer/scripts/post-create.sh b/.devcontainer/scripts/post-create.sh index 3b30e3b..fd76a1b 100644 --- a/.devcontainer/scripts/post-create.sh +++ b/.devcontainer/scripts/post-create.sh @@ -29,16 +29,16 @@ on_error() { } trap 'on_error ${LINENO} "${BASH_COMMAND}"' ERR -# Copies .env.example to .env if no .env exists yet. +# Installs lefthook git hooks for this repo. Best-effort: silently +# skips if lefthook isn't on PATH yet or no lefthook.yml exists. # # Outputs: # Writes progress to stderr via log() -setup_env_file() { - local devcontainer_dir="${SCRIPT_DIR}/.." - if [[ ! -f "${devcontainer_dir}/.env" ]] && [[ -f "${devcontainer_dir}/.env.example" ]]; then - log "Creating .env from .env.example..." - cp "${devcontainer_dir}/.env.example" "${devcontainer_dir}/.env" - fi +install_lefthook_hooks() { + command -v lefthook >/dev/null 2>&1 || return 0 + [[ -f "${SCRIPT_DIR}/../../lefthook.yml" ]] || return 0 + log "Installing lefthook git hooks..." + (cd "${SCRIPT_DIR}/../.." && lefthook install >/dev/null 2>&1) || true } # Appends shell customization sourcing block to .zshrc. @@ -80,8 +80,8 @@ EOF # Writes progress to stderr via log() main() { log "Starting post-create setup..." - setup_env_file base_setup + install_lefthook_hooks setup_shell_customization # --- Add repo-specific setup below --- log "Post-create setup completed" diff --git a/.devcontainer/scripts/startup.sh b/.devcontainer/scripts/startup.sh index c2ea806..b56613a 100644 --- a/.devcontainer/scripts/startup.sh +++ b/.devcontainer/scripts/startup.sh @@ -9,7 +9,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR -COMPOSE_FILE="$(cd "${SCRIPT_DIR}/.." && pwd)/compose.yaml" +DEVCONTAINER_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +readonly DEVCONTAINER_DIR +COMPOSE_FILE="${DEVCONTAINER_DIR}/compose.yaml" readonly COMPOSE_FILE # shellcheck source=lib/common.sh @@ -87,13 +89,13 @@ wait_for_healthy() { main() { if ! has_cmd docker; then log "Docker not available, skipping service startup" - show_motd "" + show_motd "" "${DEVCONTAINER_DIR}" return 0 fi if [[ ! -f "${COMPOSE_FILE}" ]]; then log "No compose.yaml found, skipping service startup" - show_motd "" + show_motd "" "${DEVCONTAINER_DIR}" return 0 fi @@ -102,7 +104,7 @@ main() { wait_for_healthy 60 - show_motd "${COMPOSE_FILE}" + show_motd "${COMPOSE_FILE}" "${DEVCONTAINER_DIR}" } main "$@" diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml index f5914c2..bc4475f 100644 --- a/.github/workflows/validate.yaml +++ b/.github/workflows/validate.yaml @@ -29,6 +29,21 @@ jobs: cp .devcontainer/.env.example .devcontainer/.env docker compose -f .devcontainer/compose.yaml config --quiet + env-check: + name: Env Template Sync + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Task + uses: arduino/setup-task@v2 + with: + version: 3.x + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Verify .env / .env.example stay in sync + run: | + cp .devcontainer/.env.example .devcontainer/.env + task env:check + build: name: Devcontainer Build runs-on: ubuntu-latest diff --git a/CONFIGURATION.md b/CONFIGURATION.md index e0bbbd3..3d91fbf 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -20,7 +20,7 @@ VS Code editor behavior or extension? → devcontainer.json → customizations.vscode block Credential or per-developer toggle? - → .devcontainer/.env (copy from .env.example) + → .devcontainer/.env (auto-created from .env.example on first build) Service-internal configuration (tuning, pipelines)? → config// directory @@ -168,7 +168,15 @@ Never commit secrets. Forward them from your host environment: ### Service Credentials -Dev-only credentials live in `.devcontainer/.env` (gitignored). Copy from `.env.example` on first use — `post-create.sh` does this automatically. +Dev-only credentials live in `.devcontainer/.env` (gitignored). The host-side `initializeCommand` (`scripts/initialize.sh`) copies `.env.example` → `.env` on first build, so `runArgs --env-file` has a valid file to load. The same file is also auto-discovered by Docker Compose. To reset, delete `.devcontainer/.env` and rebuild — or run `task env:reset`. + +The template follows a three-state grammar: + +| State | Syntax | Meaning | +|---|---|---| +| Filled default | `VAR=value` | Safe demo value; override only if you need something different. | +| Required (empty) | `VAR=` | Must be filled in; the MOTD warns at container start until set. | +| Optional override | `# VAR=value` | Uncomment to enable. | --- @@ -297,7 +305,8 @@ Follow the naming convention `musher-${devcontainerId}-`: | Hook | Runs | Use For | |---|---|---| -| `postCreateCommand` | Once, on container creation | Tool installation, permissions, `.env` setup | +| `initializeCommand` | Host-side, before every `docker run` | Bootstrap that must exist before the container starts (e.g., creating `.devcontainer/.env` so `--env-file` works) | +| `postCreateCommand` | Once, on container creation | Tool installation, permissions, lefthook hooks | | `postStartCommand` | Every container start | `docker compose up`, health checks | ### Skipping Base Steps @@ -368,9 +377,12 @@ AI CLI configs are stored in named volumes mounted via `devcontainer.json` → ` aliases.shared.sh Team-default shell aliases README.md Shell customization docs scripts/ + initialize.sh Host-side bootstrap (runs before docker run) post-create.sh One-time setup entry point startup.sh Every-start service launcher lib/ base-setup.sh Reusable tool installer common.sh Shared utilities + env-check.sh .env / .env.example drift detection + motd.sh Startup MOTD renderer ``` diff --git a/README.md b/README.md index 50cac28..be730b0 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,25 @@ Canonical dev container template for the musher-dev organization. Batteries-incl 1. Click **Use this template** → **Create a new repository** on GitHub 2. Clone your new repo and open in VS Code 3. **Command Palette** → **Dev Containers: Reopen in Container** -4. *(Optional)* Copy `.devcontainer/.env.example` → `.devcontainer/.env` and set `COMPOSE_PROFILES` for optional services +4. *(Optional)* Edit `.devcontainer/.env` to set `COMPOSE_PROFILES` and override credentials — the file is created automatically from `.env.example` on first build. + +## Local Environment + +All local-dev state is contained under `.devcontainer/`. On first build, `initializeCommand` copies `.env.example` → `.env` (gitignored). The same file feeds: + +- **Docker Compose** — auto-discovered as the sibling `.env` next to `compose.yaml`, used to interpolate `${VAR:-default}` references. +- **The dev container itself** — loaded via `runArgs --env-file`, so shells and runtimes inside the container see the same values. + +To reset local env state, delete `.devcontainer/.env` and rebuild. Useful task commands: + +| Command | Purpose | +|---|---| +| `task env:check` | Verify `.env` has every key from `.env.example`. | +| `task env:required` | List required keys (declared empty in the template) that still need a value. | +| `task env:diff` | Show keys present in one of `.env` / `.env.example` but not the other. | +| `task env:reset` | Re-copy the template over `.env` (prompts before overwriting). | + +The startup MOTD also warns about drift or unfilled required keys. ## Customize diff --git a/Taskfile.yml b/Taskfile.yml new file mode 100644 index 0000000..39dbd8e --- /dev/null +++ b/Taskfile.yml @@ -0,0 +1,75 @@ +version: '3' + +# Top-level tasks for the dev container itself. Project-specific tasks +# should be added in a Taskfile.yml inside the consuming repo; this file +# stays scoped to the dev environment. + +vars: + ENV_FILE: .devcontainer/.env + ENV_EXAMPLE: .devcontainer/.env.example + ENV_LIB: .devcontainer/scripts/lib/env-check.sh + +tasks: + env:check: + desc: Verify .devcontainer/.env has every key from .env.example (structural drift only). + silent: true + cmds: + - | + # shellcheck source={{.ENV_LIB}} + source "{{.ENV_LIB}}" + rc=0 + missing="$(env_check_drift "{{.ENV_FILE}}" "{{.ENV_EXAMPLE}}" 2>&1)" || rc=$? + if [[ ${rc} -ne 0 ]]; then + echo "Missing keys in {{.ENV_FILE}}:" + echo "${missing}" | sed 's/^/ - /' + echo "Run 'task env:reset' to re-copy from the template (overwrites local values)." + exit 1 + fi + echo "{{.ENV_FILE}} is in sync with {{.ENV_EXAMPLE}}." + + env:required: + desc: List required keys (declared empty in the template) that have no value in .env. + silent: true + cmds: + - | + source "{{.ENV_LIB}}" + required="$(env_check_required "{{.ENV_FILE}}")" + if [[ -z "${required}" ]]; then + echo "All required keys in {{.ENV_FILE}} have values." + exit 0 + fi + echo "Required keys with empty values in {{.ENV_FILE}}:" + echo "${required}" | sed 's/^/ - /' + exit 1 + + env:diff: + desc: Show keys present in one of .env / .env.example but not the other. + silent: true + cmds: + - | + source "{{.ENV_LIB}}" + expected="$(env_check_keys "{{.ENV_EXAMPLE}}" | sort -u)" + actual="$(env_check_keys "{{.ENV_FILE}}" | sort -u)" + missing="$(comm -23 <(echo "${expected}") <(echo "${actual}"))" + extra="$(comm -13 <(echo "${expected}") <(echo "${actual}"))" + if [[ -z "${missing}" && -z "${extra}" ]]; then + echo "Keys are in sync." + exit 0 + fi + [[ -n "${missing}" ]] && { echo "Missing from .env:"; echo "${missing}" | sed 's/^/ - /'; } + [[ -n "${extra}" ]] && { echo "Extra in .env (not in template):"; echo "${extra}" | sed 's/^/ + /'; } + + env:reset: + desc: Re-copy .env.example over .env (destroys local values). Prompts for confirmation. + silent: true + cmds: + - | + if [[ -f "{{.ENV_FILE}}" ]]; then + read -r -p "Overwrite {{.ENV_FILE}} with the template? [y/N] " ans + case "${ans}" in + y|Y|yes|YES) ;; + *) echo "Aborted."; exit 1 ;; + esac + fi + cp "{{.ENV_EXAMPLE}}" "{{.ENV_FILE}}" + echo "Wrote {{.ENV_FILE}} from {{.ENV_EXAMPLE}}." diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..b09cae8 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,16 @@ +# Lefthook git hooks. Installed by post-create.sh when lefthook is on PATH. +# See https://lefthook.dev for syntax reference. + +pre-commit: + parallel: true + commands: + block-devcontainer-env: + # Defense-in-depth against committing .devcontainer/.env even if the + # .gitignore entry is accidentally removed. The file holds local dev + # secrets and should never enter version control. + run: | + if git diff --cached --name-only --diff-filter=A | grep -qx '\.devcontainer/\.env'; then + echo "Refusing to commit .devcontainer/.env (contains local secrets)." >&2 + echo "Hint: 'git restore --staged .devcontainer/.env' to unstage." >&2 + exit 1 + fi