Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 29 additions & 9 deletions .devcontainer/.env.example
Original file line number Diff line number Diff line change
@@ -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
10 changes: 10 additions & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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$//' {} +",
Expand Down
55 changes: 55 additions & 0 deletions .devcontainer/scripts/initialize.sh
Original file line number Diff line number Diff line change
@@ -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 "$@"
67 changes: 67 additions & 0 deletions .devcontainer/scripts/lib/env-check.sh
Original file line number Diff line number Diff line change
@@ -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 <file> — print KEY names (one per line) to stdout
# env_check_drift <env> <example> — print missing keys; non-zero if any drift
# env_check_required <env> — 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 <file>}"
[[ -f "${file}" ]] || return 0
grep -E '^[A-Z_][A-Z0-9_]*=' "${file}" | sed 's/=.*//' | awk '!seen[$0]++'
}

# Reports keys present in <example> but missing from <env>.
#
# 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 <env> <example>}"
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 <env> 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 <env>}"
[[ -f "${env_file}" ]] || return 0
grep -E '^[A-Z_][A-Z0-9_]*=$' "${env_file}" | sed 's/=$//' || true
}
47 changes: 47 additions & 0 deletions .devcontainer/scripts/lib/motd.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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})"
Expand All @@ -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
Expand All @@ -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}"
Expand Down
16 changes: 8 additions & 8 deletions .devcontainer/scripts/post-create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 6 additions & 4 deletions .devcontainer/scripts/startup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -102,7 +104,7 @@ main() {

wait_for_healthy 60

show_motd "${COMPOSE_FILE}"
show_motd "${COMPOSE_FILE}" "${DEVCONTAINER_DIR}"
}

main "$@"
15 changes: 15 additions & 0 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading