From b232d6ef367a032d6e5252327bbe501544fd23f6 Mon Sep 17 00:00:00 2001 From: MangoPig Date: Mon, 6 Jul 2026 13:06:32 +0100 Subject: [PATCH] Fix: harden local dev startup flow --- Backend/Dockerfile | 3 + Commands/Local/Dev/backend.just | 15 ++++- Commands/Local/Dev/frontend.just | 6 +- Commands/Local/Dev/mod.just | 4 +- Commands/Local/Dev/scripts/backend-stack.sh | 24 ++++++-- Commands/Local/Dev/scripts/dev-stack.sh | 24 ++++++-- Commands/Local/prod.just | 25 +++++--- Commands/Local/scripts/common.sh | 65 +++++++++++++++++++++ Docker/docker-compose.local.dev.yaml | 8 ++- Docker/docker-compose.local.prod.yaml | 1 + 10 files changed, 149 insertions(+), 26 deletions(-) create mode 100644 Commands/Local/scripts/common.sh diff --git a/Backend/Dockerfile b/Backend/Dockerfile index e6e9d94..53ae4b8 100644 --- a/Backend/Dockerfile +++ b/Backend/Dockerfile @@ -6,6 +6,9 @@ WORKDIR /app RUN apk add --no-cache ca-certificates curl git tzdata && update-ca-certificates +RUN mkdir -p /tmp/home /tmp/go/pkg/mod /tmp/go-build \ + && chmod 0777 /tmp/home /tmp/go /tmp/go/pkg /tmp/go/pkg/mod /tmp/go-build + COPY go.mod go.sum ./ RUN go mod download diff --git a/Commands/Local/Dev/backend.just b/Commands/Local/Dev/backend.just index 8899786..de78802 100644 --- a/Commands/Local/Dev/backend.just +++ b/Commands/Local/Dev/backend.just @@ -1,30 +1,39 @@ project_root := justfile_directory() backend_dir := project_root + "/Backend" +common_sh := project_root + "/Commands/Local/scripts/common.sh" +posix_root := project_root + "/POSIX" # Apply embedded database migrations. migrate-up: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' cd '{{backend_dir}}' && go run ./cmd/migrate up -# Roll back the most recent embedded database migration. +# Roll back the most recent embedded database migration (confirmation required). migrate-down: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will roll back the most recent embedded database migration. Continue?"' cd '{{backend_dir}}' && go run ./cmd/migrate down -# Reset all embedded database migrations. +# Reset all embedded database migrations (confirmation required). migrate-reset: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will reset all embedded database migrations. Continue?"' cd '{{backend_dir}}' && go run ./cmd/migrate reset -# Reset embedded database migrations and apply them again from scratch. +# Reset embedded database migrations and apply them again from scratch (confirmation required). migrate-rebuild: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will reset all embedded database migrations and reapply them from scratch. Continue?"' cd '{{backend_dir}}' && go run ./cmd/migrate reset && go run ./cmd/migrate up # Show the embedded database migration status. migrate-status: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' cd '{{backend_dir}}' && go run ./cmd/migrate status # Rebuild the POSIX-to-DB projection from the current POSIX root. posix-rebuild: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' cd '{{backend_dir}}' && go run ./cmd/posix rebuild # Format backend Go source files. fmt: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' cd '{{backend_dir}}' && gofmt -w ./cmd ./db ./internal diff --git a/Commands/Local/Dev/frontend.just b/Commands/Local/Dev/frontend.just index 46ba1b6..449c63c 100644 --- a/Commands/Local/Dev/frontend.just +++ b/Commands/Local/Dev/frontend.just @@ -2,13 +2,17 @@ project_root := justfile_directory() local_compose := project_root + "/Docker/docker-compose.local.dev.yaml" frontend_dir := project_root + "/Frontend" node_modules_volume := "moku_work_frontend_node_modules" +common_sh := project_root + "/Commands/Local/scripts/common.sh" +posix_root := project_root + "/POSIX" -# Recreate the frontend node_modules Docker volume. +# Recreate the frontend node_modules Docker volume (confirmation required). node_modules: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will recreate the frontend node_modules Docker volume. Continue?"' docker compose -f '{{local_compose}}' rm -sf frontend >/dev/null 2>&1 || true docker volume rm -f '{{node_modules_volume}}' >/dev/null 2>&1 || true docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate frontend # Run the frontend TypeScript check. tsc: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' cd '{{frontend_dir}}' && pnpm typecheck diff --git a/Commands/Local/Dev/mod.just b/Commands/Local/Dev/mod.just index a4f0add..9d37522 100644 --- a/Commands/Local/Dev/mod.just +++ b/Commands/Local/Dev/mod.just @@ -18,7 +18,7 @@ start: # Alias for the main full local development flow. dev: up -# Stop and remove the local development stack. +# Stop and remove the local development stack (confirmation required). down: bash '{{stack_runner}}' down @@ -34,6 +34,6 @@ logs: restart: bash '{{stack_runner}}' restart -# Stop the local development stack and remove local images, volumes, and backend dev state. +# Stop the local development stack and remove local images, volumes, backend dev state, and the local POSIX folder (confirmation required). clean: bash '{{stack_runner}}' clean diff --git a/Commands/Local/Dev/scripts/backend-stack.sh b/Commands/Local/Dev/scripts/backend-stack.sh index 3d1ed09..1bb738e 100644 --- a/Commands/Local/Dev/scripts/backend-stack.sh +++ b/Commands/Local/Dev/scripts/backend-stack.sh @@ -7,6 +7,7 @@ action=${1:-up} script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) project_root=$(cd -- "$script_dir/../../../.." && pwd) +posix_root="$project_root/POSIX" backend_dir="$project_root/Backend" backend_bake="$backend_dir/docker-bake.hcl" env_dir="$project_root/Env" @@ -15,11 +16,14 @@ runtime_dir="$backend_dir/tmp/dev" backend_image="moku/work-backend:dev" backend_go_pkg_volume="moku_work_backend_go_pkg" backend_go_build_volume="moku_work_backend_go_build" +local_uid=$(id -u) +local_gid=$(id -g) services=(web api worker) source "$script_dir/docker.sh" source "$script_dir/env.sh" +source "$project_root/Commands/Local/scripts/common.sh" build_backend() { cd "$backend_dir" @@ -27,20 +31,20 @@ build_backend() { } up_backend() { - docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}" + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" up -d --remove-orphans --force-recreate "${services[@]}" } down_backend() { - docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true - docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" stop "${services[@]}" >/dev/null 2>&1 || true + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" rm -f "${services[@]}" >/dev/null 2>&1 || true } restart_backend() { - docker compose -f "$compose_file" restart "${services[@]}" + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" restart "${services[@]}" } follow_logs() { - docker compose -f "$compose_file" logs -f "${services[@]}" + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" logs -f "${services[@]}" } clean_runtime() { @@ -50,34 +54,44 @@ clean_runtime() { case "$action" in check) ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' + ensure_posix_root "$project_root" "$posix_root" ;; build) ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' + ensure_posix_root "$project_root" "$posix_root" build_backend ;; up) ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_local_env_file "$env_dir" + ensure_posix_root "$project_root" "$posix_root" up_backend + run_compose_api_migrations "$compose_file" "api" ;; down) ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_local_env_file "$env_dir" + ensure_posix_root "$project_root" "$posix_root" + confirm_destructive_action 'This will stop and remove the local backend dev containers. Continue?' down_backend ;; restart) ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_local_env_file "$env_dir" + ensure_posix_root "$project_root" "$posix_root" restart_backend ;; logs) ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_local_env_file "$env_dir" + ensure_posix_root "$project_root" "$posix_root" follow_logs ;; clean) ensure_docker 'docker is required for the local backend dev runtime. Install Docker first.' ensure_local_env_file "$env_dir" + ensure_posix_root "$project_root" "$posix_root" + confirm_destructive_action 'This will remove the local backend dev containers, images, volumes, and runtime state. Continue?' down_backend remove_docker_image_if_present "$backend_image" remove_docker_volume_if_present "$backend_go_pkg_volume" diff --git a/Commands/Local/Dev/scripts/dev-stack.sh b/Commands/Local/Dev/scripts/dev-stack.sh index 5f3e7a4..cb4160b 100644 --- a/Commands/Local/Dev/scripts/dev-stack.sh +++ b/Commands/Local/Dev/scripts/dev-stack.sh @@ -7,12 +7,15 @@ action=${1:-up} script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) project_root=$(cd -- "$script_dir/../../../.." && pwd) +posix_root="$project_root/POSIX" frontend_dir="$project_root/Frontend" frontend_bake="$frontend_dir/docker-bake.hcl" backend_dir="$project_root/Backend" backend_bake="$backend_dir/docker-bake.hcl" env_dir="$project_root/Env" compose_file="$project_root/Docker/docker-compose.local.dev.yaml" +local_uid=$(id -u) +local_gid=$(id -g) frontend_image="moku/work-frontend:dev" backend_image="moku/work-backend:dev" frontend_volume="moku_work_frontend_node_modules" @@ -22,6 +25,7 @@ backend_runtime_dir="$backend_dir/tmp/dev" source "$script_dir/docker.sh" source "$script_dir/env.sh" +source "$project_root/Commands/Local/scripts/common.sh" build_frontend() { cd "$frontend_dir" @@ -39,19 +43,19 @@ build_images() { } up_stack() { - docker compose -f "$compose_file" up -d --remove-orphans --force-recreate + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" up -d --remove-orphans --force-recreate } down_stack() { - docker compose -f "$compose_file" down --remove-orphans --volumes + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" down --remove-orphans --volumes } follow_logs() { - docker compose -f "$compose_file" logs -f + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" logs -f } clean_stack() { - docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" down --remove-orphans --volumes >/dev/null 2>&1 || true remove_docker_image_if_present "$frontend_image" remove_docker_image_if_present "$backend_image" remove_docker_volume_if_present "$frontend_volume" @@ -63,37 +67,47 @@ clean_stack() { start_stack() { build_images up_stack + run_compose_api_migrations "$compose_file" "api" } case "$action" in build) ensure_docker 'docker is required for the local development stack. Install Docker first.' + ensure_posix_root "$project_root" "$posix_root" build_images ;; up|start|rebuild) ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_local_env_file "$env_dir" + ensure_posix_root "$project_root" "$posix_root" start_stack ;; down) ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_local_env_file "$env_dir" + ensure_posix_root "$project_root" "$posix_root" + confirm_destructive_action 'This will stop the local development stack and remove its containers and volumes. Continue?' down_stack ;; restart) ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_local_env_file "$env_dir" - docker compose -f "$compose_file" restart + ensure_posix_root "$project_root" "$posix_root" + LOCAL_UID="$local_uid" LOCAL_GID="$local_gid" docker compose -f "$compose_file" restart ;; logs) ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_local_env_file "$env_dir" + ensure_posix_root "$project_root" "$posix_root" follow_logs ;; clean) ensure_docker 'docker is required for the local development stack. Install Docker first.' ensure_local_env_file "$env_dir" + ensure_posix_root "$project_root" "$posix_root" + confirm_destructive_action 'This will remove the local development stack, local images, volumes, backend dev state, and the local POSIX folder. Continue?' clean_stack + rm -rf "$posix_root" ;; *) printf 'Unsupported dev stack action: %s\n' "$action" >&2 diff --git a/Commands/Local/prod.just b/Commands/Local/prod.just index 42ab001..3459a13 100644 --- a/Commands/Local/prod.just +++ b/Commands/Local/prod.just @@ -5,40 +5,49 @@ local_compose := project_root + "/Docker/docker-compose.local.prod.yaml" proxy_image := "moku/work-proxy:local-prod" backend_api_image := "moku/work-backend:local-prod-api" backend_worker_image := "moku/work-backend:local-prod-worker" +common_sh := project_root + "/Commands/Local/scripts/common.sh" +posix_root := project_root + "/POSIX" # Build the local production proxy image locally. build: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' prod cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' prod-api prod-worker # Start the local production stack in the background using the current image. up: - docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' + LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate # Build first, then start the local production stack in the background. start: build up # Rebuild the local production proxy image locally. rebuild: + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' cd '{{project_root}}' && docker buildx bake -f '{{proxy_bake}}' --set '*.no-cache=true' prod cd '{{project_root}}' && docker buildx bake -f '{{backend_bake}}' --set '*.no-cache=true' prod-api prod-worker - docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate + LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' up -d --remove-orphans --force-recreate -# Stop and remove the local production stack. +# Stop and remove the local production stack (confirmation required). down: - docker compose -f '{{local_compose}}' down --remove-orphans --volumes + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will stop the local production stack and remove its containers and volumes. Continue?"' + LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' down --remove-orphans --volumes # Follow logs for the local production stack. logs: - docker compose -f '{{local_compose}}' logs -f + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' + LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' logs -f # Restart the local production stack. restart: - docker compose -f '{{local_compose}}' restart + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"' + LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' restart -# Stop the local production stack and remove local images. +# Stop the local production stack and remove local images (confirmation required). clean: - docker compose -f '{{local_compose}}' down --remove-orphans --volumes + bash -c 'source "{{common_sh}}"; ensure_posix_root "{{project_root}}" "{{posix_root}}"; confirm_destructive_action "This will remove the local production stack, its volumes, and the local production images. Continue?"' + LOCAL_UID="$$(id -u)" LOCAL_GID="$$(id -g)" docker compose -f '{{local_compose}}' down --remove-orphans --volumes docker image rm -f '{{proxy_image}}' >/dev/null 2>&1 || true docker image rm -f '{{backend_api_image}}' >/dev/null 2>&1 || true docker image rm -f '{{backend_worker_image}}' >/dev/null 2>&1 || true diff --git a/Commands/Local/scripts/common.sh b/Commands/Local/scripts/common.sh new file mode 100644 index 0000000..d8b8e7a --- /dev/null +++ b/Commands/Local/scripts/common.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ensure_posix_root() { + local project_root=${1:?project root is required} + local posix_root=${2:-"$project_root/POSIX"} + + mkdir -p "$posix_root" +} + +confirm_destructive_action() { + local prompt=${1:-Are you sure you want to continue?} + local response_input="" + + if [[ "${JUST_YES:-0}" == "1" ]]; then + return 0 + fi + + if [[ -t 0 ]]; then + printf '%s [y/N] ' "$prompt" + IFS= read -r response_input + elif [[ -r /dev/tty ]]; then + printf '%s [y/N] ' "$prompt" > /dev/tty + IFS= read -r response_input < /dev/tty + else + printf 'Refusing destructive action without interactive confirmation. Re-run with JUST_YES=1 to continue.\n' >&2 + exit 1 + fi + + case "$response_input" in + y|Y|yes|YES|Yes) + return 0 + ;; + *) + printf 'Aborted.\n' >&2 + exit 1 + ;; + esac +} + +run_compose_api_migrations() { + local compose_file=${1:?compose file is required} + local service_name=${2:-api} + local max_attempts=${3:-20} + local sleep_seconds=${4:-2} + local attempt=1 + local output="" + + while (( attempt <= max_attempts )); do + if output=$(docker compose -f "$compose_file" exec -T "$service_name" sh -lc 'go run ./cmd/migrate up' 2>&1); then + printf '%s\n' "$output" + return 0 + fi + + if (( attempt == max_attempts )); then + printf 'Failed to apply database migrations automatically after %d attempts.\n' "$max_attempts" >&2 + printf '%s\n' "$output" >&2 + return 1 + fi + + sleep "$sleep_seconds" + attempt=$((attempt + 1)) + done +} diff --git a/Docker/docker-compose.local.dev.yaml b/Docker/docker-compose.local.dev.yaml index d35ea79..8e62d8f 100644 --- a/Docker/docker-compose.local.dev.yaml +++ b/Docker/docker-compose.local.dev.yaml @@ -1,12 +1,16 @@ x-backend-service: &backend-service image: moku/work-backend:dev restart: unless-stopped + user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" env_file: - ../Env/.env.local environment: DATABASE_URL: postgres://moku:moku_dev_password@postgres:5432/moku?sslmode=disable VALKEY_URL: redis://valkey:6379/0 POSIX_ROOT: /posix + HOME: /tmp/home + GOMODCACHE: /tmp/go/pkg/mod + GOCACHE: /tmp/go-build depends_on: postgres: condition: service_healthy @@ -15,8 +19,8 @@ x-backend-service: &backend-service volumes: - ../Backend:/app - ../POSIX:/posix - - moku_work_backend_go_pkg:/go/pkg/mod - - moku_work_backend_go_build:/root/.cache/go-build + - moku_work_backend_go_pkg:/tmp/go/pkg/mod + - moku_work_backend_go_build:/tmp/go-build services: postgres: diff --git a/Docker/docker-compose.local.prod.yaml b/Docker/docker-compose.local.prod.yaml index f18dd3f..227e0d7 100644 --- a/Docker/docker-compose.local.prod.yaml +++ b/Docker/docker-compose.local.prod.yaml @@ -1,5 +1,6 @@ x-backend-service: &backend-service restart: unless-stopped + user: "${LOCAL_UID:-1000}:${LOCAL_GID:-1000}" env_file: - ../Env/.env.local environment: