From a746fe4cabb8b00e672a91b654f177c95861bc4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 13:36:34 +0000 Subject: [PATCH 01/10] Add Docker setup script for automated deployment configuration Replaces manual editing of docker-compose.yaml with a setup script that generates secure random passwords, creates a .env file, generates SSL certificates, and creates required directories. Docker Compose files now use ${VAR} substitution from .env with safe defaults. New files: - docker-setup.sh: Interactive/auto setup script - .env.example: Documented template for environment configuration https://claude.ai/code/session_01KdeKniaCeDDV1UvnkTLQcb --- .env.example | 35 +++++ .gitignore | 1 + docker-compose-build.yaml | 31 ++-- docker-compose.yaml | 31 ++-- docker-setup.sh | 292 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 354 insertions(+), 36 deletions(-) create mode 100644 .env.example create mode 100755 docker-setup.sh diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..36324f9d7 --- /dev/null +++ b/.env.example @@ -0,0 +1,35 @@ +# ============================================================================ +# Dungeon Master's Vault — Docker Environment Configuration +# +# Copy this file to .env and update the values: +# cp .env.example .env +# +# Or run the setup script to generate .env with secure random values: +# ./docker-setup.sh +# ============================================================================ + +# --- Application --- +PORT=8890 + +# --- Datomic Database --- +# ADMIN_PASSWORD secures the Datomic admin interface +# DATOMIC_PASSWORD is used by the application to connect to Datomic +# The password in DATOMIC_URL must match DATOMIC_PASSWORD +ADMIN_PASSWORD=change-me-admin +DATOMIC_PASSWORD=change-me-datomic +DATOMIC_URL=datomic:free://datomic:4334/orcpub?password=change-me-datomic + +# --- Security --- +# Secret used to sign JWT tokens (20+ characters recommended) +SIGNATURE=change-me-to-something-unique-and-long + +# --- Email (SMTP) --- +# Leave EMAIL_SERVER_URL empty to disable email functionality +EMAIL_SERVER_URL= +EMAIL_ACCESS_KEY= +EMAIL_SECRET_KEY= +EMAIL_SERVER_PORT=587 +EMAIL_FROM_ADDRESS= +EMAIL_ERRORS_TO= +EMAIL_SSL=FALSE +EMAIL_TLS=FALSE diff --git a/.gitignore b/.gitignore index 3e3c55535..a49906f50 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,7 @@ pom.xml orcpub.iml profiles.clj env.sh +.env .repl .nrepl-port *~ diff --git a/docker-compose-build.yaml b/docker-compose-build.yaml index 296e97768..611a34958 100644 --- a/docker-compose-build.yaml +++ b/docker-compose-build.yaml @@ -6,21 +6,17 @@ services: context: . dockerfile: docker/orcpub/Dockerfile environment: - PORT: 8890 - EMAIL_SERVER_URL: '' - EMAIL_ACCESS_KEY: '' - EMAIL_SECRET_KEY: '' - EMAIL_SERVER_PORT: 587 - # Email address to send from, will default to 'no-reply@orcpub.com' - EMAIL_FROM_ADDRESS: '' - # Email address to send errors to - EMAIL_ERRORS_TO: '' - EMAIL_SSL: 'TRUE' - EMAIL_TLS: 'FALSE' - # Datomic connection string - Make sure the matches the DATOMIC_PASSWORD below - DATOMIC_URL: datomic:free://datomic:4334/orcpub?password= - # The secret used to hash your password in the browser, 20+ characters recommended - SIGNATURE: '' + PORT: ${PORT:-8890} + EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} + EMAIL_ACCESS_KEY: ${EMAIL_ACCESS_KEY:-} + EMAIL_SECRET_KEY: ${EMAIL_SECRET_KEY:-} + EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT:-587} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} + EMAIL_SSL: ${EMAIL_SSL:-FALSE} + EMAIL_TLS: ${EMAIL_TLS:-FALSE} + DATOMIC_URL: ${DATOMIC_URL:-datomic:free://datomic:4334/orcpub?password=change-me} + SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} depends_on: - datomic restart: always @@ -29,9 +25,8 @@ services: context: . dockerfile: docker/datomic/Dockerfile environment: - ADMIN_PASSWORD: - # Must match the in the DATOMIC_URL above. - DATOMIC_PASSWORD: + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} + DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} volumes: - ./data:/data - ./logs:/logs diff --git a/docker-compose.yaml b/docker-compose.yaml index 5ebaa5048..23e242306 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -4,30 +4,25 @@ services: orcpub: image: orcpub/orcpub:latest environment: - PORT: 8890 - EMAIL_SERVER_URL: '' - EMAIL_ACCESS_KEY: '' - EMAIL_SECRET_KEY: '' - EMAIL_SERVER_PORT: 587 - # Email address to send from, will default to 'no-reply@orcpub.com' - EMAIL_FROM_ADDRESS: '' - # Email address to send errors to - EMAIL_ERRORS_TO: '' - EMAIL_SSL: 'FALSE' - EMAIL_TLS: 'FALSE' - # Datomic connection string - Make sure the matches the DATOMIC_PASSWORD below - DATOMIC_URL: datomic:free://datomic:4334/orcpub?password= - # The secret used to hash your password in the browser, 20+ characters recommended - SIGNATURE: '' + PORT: ${PORT:-8890} + EMAIL_SERVER_URL: ${EMAIL_SERVER_URL:-} + EMAIL_ACCESS_KEY: ${EMAIL_ACCESS_KEY:-} + EMAIL_SECRET_KEY: ${EMAIL_SECRET_KEY:-} + EMAIL_SERVER_PORT: ${EMAIL_SERVER_PORT:-587} + EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:-} + EMAIL_ERRORS_TO: ${EMAIL_ERRORS_TO:-} + EMAIL_SSL: ${EMAIL_SSL:-FALSE} + EMAIL_TLS: ${EMAIL_TLS:-FALSE} + DATOMIC_URL: ${DATOMIC_URL:-datomic:free://datomic:4334/orcpub?password=change-me} + SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} depends_on: - datomic restart: always datomic: image: orcpub/datomic:latest environment: - ADMIN_PASSWORD: - # Must match the in the DATOMIC_URL above. - DATOMIC_PASSWORD: + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me-admin} + DATOMIC_PASSWORD: ${DATOMIC_PASSWORD:-change-me} volumes: - ./data:/data - ./logs:/logs diff --git a/docker-setup.sh b/docker-setup.sh new file mode 100755 index 000000000..9caf9b238 --- /dev/null +++ b/docker-setup.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +# +# OrcPub / Dungeon Master's Vault — Docker Setup Script +# +# Prepares everything needed to run the application via Docker Compose: +# 1. Generates secure random passwords and a signing secret +# 2. Creates a .env file (or uses an existing one) +# 3. Generates self-signed SSL certificates (if missing) +# 4. Creates required directories (data, logs, deploy/homebrew) +# +# Usage: +# ./docker-setup.sh # Interactive mode — prompts for optional values +# ./docker-setup.sh --auto # Non-interactive — accepts all defaults +# ./docker-setup.sh --help # Show usage +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${SCRIPT_DIR}/.env" +ENV_EXAMPLE="${SCRIPT_DIR}/.env.example" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +color_green='\033[0;32m' +color_yellow='\033[1;33m' +color_red='\033[0;31m' +color_cyan='\033[0;36m' +color_reset='\033[0m' + +info() { printf "${color_green}[INFO]${color_reset} %s\n" "$*"; } +warn() { printf "${color_yellow}[WARN]${color_reset} %s\n" "$*"; } +error() { printf "${color_red}[ERROR]${color_reset} %s\n" "$*" >&2; } +header() { printf "\n${color_cyan}=== %s ===${color_reset}\n\n" "$*"; } + +generate_password() { + # Generate a URL-safe random password (no special chars that break URLs/YAML) + local length="${1:-24}" + if command -v openssl &>/dev/null; then + openssl rand -base64 "$length" | tr -d '/+=' | head -c "$length" + elif [ -r /dev/urandom ]; then + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$length" + else + error "Cannot generate random password: no openssl or /dev/urandom available" + exit 1 + fi +} + +prompt_value() { + local prompt_text="$1" + local default_value="$2" + local result + + if [ "${AUTO_MODE:-false}" = "true" ]; then + echo "$default_value" + return + fi + + if [ -n "$default_value" ]; then + read -rp "${prompt_text} [${default_value}]: " result + echo "${result:-$default_value}" + else + read -rp "${prompt_text}: " result + echo "$result" + fi +} + +usage() { + cat <<'USAGE' +Usage: ./docker-setup.sh [OPTIONS] + +Options: + --auto Non-interactive mode; accept all defaults + --force Overwrite existing .env file + --help Show this help message + +Examples: + ./docker-setup.sh # Interactive setup + ./docker-setup.sh --auto # Quick setup with generated defaults + ./docker-setup.sh --auto --force # Regenerate everything from scratch +USAGE +} + +# --------------------------------------------------------------------------- +# Parse arguments +# --------------------------------------------------------------------------- + +AUTO_MODE=false +FORCE_MODE=false + +for arg in "$@"; do + case "$arg" in + --auto) AUTO_MODE=true ;; + --force) FORCE_MODE=true ;; + --help) usage; exit 0 ;; + *) + error "Unknown option: $arg" + usage + exit 1 + ;; + esac +done + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +header "Dungeon Master's Vault — Docker Setup" + +# ---- Step 1: .env file --------------------------------------------------- + +if [ -f "$ENV_FILE" ] && [ "$FORCE_MODE" = "false" ]; then + info "Existing .env file found. Skipping generation (use --force to overwrite)." +else + info "Generating secure credentials..." + + ADMIN_PASSWORD="$(generate_password 24)" + DATOMIC_PASSWORD="$(generate_password 24)" + SIGNATURE="$(generate_password 32)" + + header "Configuration" + + PORT=$(prompt_value "Application port" "8890") + EMAIL_SERVER_URL=$(prompt_value "SMTP server URL (leave empty to skip email)" "") + EMAIL_ACCESS_KEY="" + EMAIL_SECRET_KEY="" + EMAIL_SERVER_PORT="587" + EMAIL_FROM_ADDRESS="" + EMAIL_ERRORS_TO="" + EMAIL_SSL="FALSE" + EMAIL_TLS="FALSE" + + if [ -n "$EMAIL_SERVER_URL" ]; then + EMAIL_ACCESS_KEY=$(prompt_value "SMTP username" "") + EMAIL_SECRET_KEY=$(prompt_value "SMTP password" "") + EMAIL_SERVER_PORT=$(prompt_value "SMTP port" "587") + EMAIL_FROM_ADDRESS=$(prompt_value "From email address" "no-reply@orcpub.com") + EMAIL_ERRORS_TO=$(prompt_value "Error notification email" "") + EMAIL_SSL=$(prompt_value "Use SSL? (TRUE/FALSE)" "FALSE") + EMAIL_TLS=$(prompt_value "Use TLS? (TRUE/FALSE)" "FALSE") + fi + + info "Writing .env file..." + + cat > "$ENV_FILE" </dev/null; then + info "Generating self-signed SSL certificate..." + openssl req \ + -subj "/C=US/ST=State/L=City/O=OrcPub/OU=Dev/CN=localhost" \ + -x509 \ + -nodes \ + -days 365 \ + -newkey rsa:2048 \ + -keyout "$KEY_FILE" \ + -out "$CERT_FILE" \ + 2>/dev/null + info "SSL certificate generated (valid for 365 days)." + else + warn "openssl not found — cannot generate SSL certificates." + warn "Install openssl and run: ./deploy/snakeoil.sh" + fi +fi + +# ---- Step 4: Validation -------------------------------------------------- + +header "Validation" + +ERRORS=0 + +check_file() { + local label="$1" path="$2" + if [ -f "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING (${path})" + ERRORS=$((ERRORS + 1)) + fi +} + +check_dir() { + local label="$1" path="$2" + if [ -d "$path" ]; then + info " ${label}: OK" + else + warn " ${label}: MISSING (${path})" + ERRORS=$((ERRORS + 1)) + fi +} + +check_file ".env" "$ENV_FILE" +check_file "docker-compose.yaml" "${SCRIPT_DIR}/docker-compose.yaml" +check_file "nginx.conf" "${SCRIPT_DIR}/deploy/nginx.conf" +check_file "SSL certificate" "$CERT_FILE" +check_file "SSL key" "$KEY_FILE" +check_dir "data/" "${SCRIPT_DIR}/data" +check_dir "logs/" "${SCRIPT_DIR}/logs" +check_dir "deploy/homebrew/" "${SCRIPT_DIR}/deploy/homebrew" + +echo "" + +if [ "$ERRORS" -gt 0 ]; then + warn "Setup completed with ${ERRORS} warning(s). Review the items above." +else + info "All checks passed!" +fi + +# ---- Step 5: Next steps --------------------------------------------------- + +header "Next Steps" + +cat <<'NEXT' +1. Review your .env file and adjust values if needed. + +2. Launch the application: + docker-compose up -d + +3. Access the site at: + https://localhost + +4. To import homebrew content, place your .orcbrew file at: + deploy/homebrew/homebrew.orcbrew + +5. To build from source instead of pulling images: + docker-compose -f docker-compose-build.yaml build + docker-compose -f docker-compose-build.yaml up -d + +For more details, see README.md. +NEXT From 28cede51bd2760041dafa564c738f3922c8c40f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 13:47:13 +0000 Subject: [PATCH 02/10] Add Docker user management script for injecting users into Datomic Solves the problem of managing users when running in Docker, where connecting an external REPL to the containerized Datomic has always failed. Uses docker exec + clojure.main against the uberjar classpath to run Datomic peer operations inside the container. New files: - docker-user.sh: Shell wrapper that finds the orcpub container, waits for Datomic readiness, and runs commands - docker/scripts/manage-user.clj: Clojure script supporting create (auto-verified), verify, check, and list operations Also updates docker-setup.sh to prompt for passwords interactively instead of silently generating them. https://claude.ai/code/session_01KdeKniaCeDDV1UvnkTLQcb --- docker-setup.sh | 29 +++-- docker-user.sh | 198 +++++++++++++++++++++++++++++++++ docker/scripts/manage-user.clj | 154 +++++++++++++++++++++++++ 3 files changed, 373 insertions(+), 8 deletions(-) create mode 100755 docker-user.sh create mode 100644 docker/scripts/manage-user.clj diff --git a/docker-setup.sh b/docker-setup.sh index 9caf9b238..e03c527de 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -114,13 +114,18 @@ header "Dungeon Master's Vault — Docker Setup" if [ -f "$ENV_FILE" ] && [ "$FORCE_MODE" = "false" ]; then info "Existing .env file found. Skipping generation (use --force to overwrite)." else - info "Generating secure credentials..." + header "Database Passwords" - ADMIN_PASSWORD="$(generate_password 24)" - DATOMIC_PASSWORD="$(generate_password 24)" - SIGNATURE="$(generate_password 32)" + # Generate defaults but let user override + DEFAULT_ADMIN_PW="$(generate_password 24)" + DEFAULT_DATOMIC_PW="$(generate_password 24)" + DEFAULT_SIGNATURE="$(generate_password 32)" - header "Configuration" + ADMIN_PASSWORD=$(prompt_value "Datomic admin password" "$DEFAULT_ADMIN_PW") + DATOMIC_PASSWORD=$(prompt_value "Datomic application password" "$DEFAULT_DATOMIC_PW") + SIGNATURE=$(prompt_value "JWT signing secret (20+ chars)" "$DEFAULT_SIGNATURE") + + header "Application" PORT=$(prompt_value "Application port" "8890") EMAIL_SERVER_URL=$(prompt_value "SMTP server URL (leave empty to skip email)" "") @@ -278,13 +283,21 @@ cat <<'NEXT' 2. Launch the application: docker-compose up -d -3. Access the site at: +3. Create your first user (once containers are running): + ./docker-user.sh create + +4. Access the site at: https://localhost -4. To import homebrew content, place your .orcbrew file at: +5. Manage users later with: + ./docker-user.sh list # List all users + ./docker-user.sh check # Check a user's status + ./docker-user.sh verify # Verify an unverified user + +6. To import homebrew content, place your .orcbrew file at: deploy/homebrew/homebrew.orcbrew -5. To build from source instead of pulling images: +7. To build from source instead of pulling images: docker-compose -f docker-compose-build.yaml build docker-compose -f docker-compose-build.yaml up -d diff --git a/docker-user.sh b/docker-user.sh new file mode 100755 index 000000000..f1b4ca68e --- /dev/null +++ b/docker-user.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# +# OrcPub Docker User Management +# +# Injects and verifies users in the Datomic database running inside Docker. +# Works by executing Clojure code inside the orcpub container, using the +# uberjar classpath (which already has datomic.api and buddy.hashers). +# +# Usage: +# ./docker-user.sh create +# ./docker-user.sh verify +# ./docker-user.sh check +# ./docker-user.sh list +# +# The script auto-detects the orcpub container name from docker-compose. +# + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MANAGE_SCRIPT="${SCRIPT_DIR}/docker/scripts/manage-user.clj" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +color_green='\033[0;32m' +color_red='\033[0;31m' +color_yellow='\033[1;33m' +color_reset='\033[0m' + +info() { printf "${color_green}[OK]${color_reset} %s\n" "$*"; } +error() { printf "${color_red}[ERROR]${color_reset} %s\n" "$*" >&2; } +warn() { printf "${color_yellow}[WARN]${color_reset} %s\n" "$*"; } + +usage() { + cat <<'USAGE' +OrcPub Docker User Management + +Usage: + ./docker-user.sh create + Create a new user (auto-verified, skips email) + + ./docker-user.sh verify + Verify an existing unverified user + + ./docker-user.sh check + Check if a user exists and show their status + + ./docker-user.sh list + List all users in the database + +Options: + --container Override container name detection + --help Show this help + +Examples: + ./docker-user.sh create admin admin@example.com MySecurePass123 + ./docker-user.sh check admin + ./docker-user.sh list +USAGE +} + +# --------------------------------------------------------------------------- +# Find the orcpub container +# --------------------------------------------------------------------------- + +find_container() { + local container="" + + # Try docker-compose/docker compose service name first + if command -v docker-compose &>/dev/null; then + container=$(docker-compose ps -q orcpub 2>/dev/null || true) + fi + if [ -z "$container" ] && docker compose version &>/dev/null 2>&1; then + container=$(docker compose ps -q orcpub 2>/dev/null || true) + fi + + # Fallback: search by image name + if [ -z "$container" ]; then + container=$(docker ps -q --filter "ancestor=orcpub/orcpub:latest" 2>/dev/null | head -1 || true) + fi + + # Fallback: search by container name pattern + if [ -z "$container" ]; then + container=$(docker ps -q --filter "name=orcpub" 2>/dev/null | head -1 || true) + fi + + echo "$container" +} + +# --------------------------------------------------------------------------- +# Wait for container and Datomic to be ready +# --------------------------------------------------------------------------- + +wait_for_ready() { + local container="$1" + local max_wait=30 + local waited=0 + + # Check container is running + if ! docker inspect --format='{{.State.Running}}' "$container" 2>/dev/null | grep -q true; then + error "Container $container is not running." + error "Start it first: docker-compose up -d" + exit 1 + fi + + # Wait for the app to have connected to Datomic (the uberjar starts the + # Component system which connects on boot). We test by attempting a + # trivial Datomic query via clojure.main. + printf "Waiting for Datomic connection" + while [ $waited -lt $max_wait ]; do + if docker exec "$container" java -cp /orcpub.jar clojure.main -e \ + '(require (quote [datomic.api :as d])) (d/connect (System/getenv "DATOMIC_URL")) (println "ready")' \ + 2>/dev/null | grep -q "ready"; then + echo "" + return 0 + fi + printf "." + sleep 2 + waited=$((waited + 2)) + done + + echo "" + error "Timed out waiting for Datomic (${max_wait}s). Is the datomic container running?" + exit 1 +} + +# --------------------------------------------------------------------------- +# Run the management script inside the container +# --------------------------------------------------------------------------- + +run_in_container() { + local container="$1" + shift + + # Copy the management script into the container + docker cp "$MANAGE_SCRIPT" "${container}:/tmp/manage-user.clj" + + # Run it with the uberjar classpath + docker exec "$container" \ + java -cp /orcpub.jar clojure.main /tmp/manage-user.clj "$@" +} + +# --------------------------------------------------------------------------- +# Parse args and dispatch +# --------------------------------------------------------------------------- + +CONTAINER_OVERRIDE="" + +# Extract --container flag if present +ARGS=() +while [[ $# -gt 0 ]]; do + case "$1" in + --container) + CONTAINER_OVERRIDE="$2" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + ARGS+=("$1") + shift + ;; + esac +done + +set -- "${ARGS[@]+"${ARGS[@]}"}" + +if [ $# -eq 0 ]; then + usage + exit 1 +fi + +# Verify manage-user.clj exists +if [ ! -f "$MANAGE_SCRIPT" ]; then + error "Management script not found at: $MANAGE_SCRIPT" + exit 1 +fi + +# Find or use specified container +if [ -n "$CONTAINER_OVERRIDE" ]; then + CONTAINER="$CONTAINER_OVERRIDE" +else + CONTAINER=$(find_container) +fi + +if [ -z "$CONTAINER" ]; then + error "Cannot find the orcpub container." + error "Make sure the containers are running: docker-compose up -d" + exit 1 +fi + +# Wait for Datomic to be reachable, then run the command +wait_for_ready "$CONTAINER" +run_in_container "$CONTAINER" "$@" diff --git a/docker/scripts/manage-user.clj b/docker/scripts/manage-user.clj new file mode 100644 index 000000000..1f3a1f589 --- /dev/null +++ b/docker/scripts/manage-user.clj @@ -0,0 +1,154 @@ +;; OrcPub Docker User Management Script +;; +;; Runs inside the orcpub container using the uberjar classpath: +;; java -cp /orcpub.jar clojure.main /scripts/manage-user.clj [args...] +;; +;; Commands: +;; create — Create and auto-verify a user +;; verify — Verify an existing unverified user +;; check — Check if a user exists and their status +;; list — List all users (username + email + verified) + +(ns manage-user + (:require [datomic.api :as d] + [buddy.hashers :as hashers] + [clojure.string :as s])) + +(def datomic-url + (or (System/getenv "DATOMIC_URL") + "datomic:free://datomic:4334/orcpub?password=datomic")) + +(defn get-conn [] + (try + (d/connect datomic-url) + (catch Exception e + (binding [*out* *err*] + (println "ERROR: Cannot connect to Datomic at" datomic-url) + (println " Is the transactor running? Cause:" (.getMessage e))) + (System/exit 1)))) + +(defn find-user [db username-or-email] + (d/q '[:find (pull ?e [:orcpub.user/username + :orcpub.user/email + :orcpub.user/verified? + :orcpub.user/created + :db/id]) . + :in $ ?needle + :where + (or [?e :orcpub.user/username ?needle] + [?e :orcpub.user/email ?needle])] + db + username-or-email)) + +(defn create-user! [conn username email password] + (let [db (d/db conn) + email (s/lower-case (s/trim email)) + username (s/trim username)] + ;; Check for duplicates + (when (d/q '[:find ?e . :in $ ?email + :where [?e :orcpub.user/email ?email]] db email) + (binding [*out* *err*] + (println "ERROR: Email already registered:" email)) + (System/exit 1)) + (when (d/q '[:find ?e . :in $ ?username + :where [?e :orcpub.user/username ?username]] db username) + (binding [*out* *err*] + (println "ERROR: Username already taken:" username)) + (System/exit 1)) + ;; Create user — already verified, no email step needed + @(d/transact conn + [{:orcpub.user/email email + :orcpub.user/username username + :orcpub.user/password (hashers/encrypt password) + :orcpub.user/verified? true + :orcpub.user/send-updates? false + :orcpub.user/created (java.util.Date.)}]) + (println "OK: User created and verified —" username "<" email ">"))) + +(defn verify-user! [conn username-or-email] + (let [db (d/db conn) + user (find-user db username-or-email)] + (if-not user + (do (binding [*out* *err*] + (println "ERROR: User not found:" username-or-email)) + (System/exit 1)) + (if (:orcpub.user/verified? user) + (println "OK: User already verified —" (:orcpub.user/username user)) + (do + @(d/transact conn + [[:db/add (:db/id user) :orcpub.user/verified? true]]) + (println "OK: User verified —" (:orcpub.user/username user))))))) + +(defn check-user [db username-or-email] + (if-let [user (find-user db username-or-email)] + (do + (println "Found user:") + (println " Username:" (:orcpub.user/username user)) + (println " Email: " (:orcpub.user/email user)) + (println " Verified:" (:orcpub.user/verified? user)) + (println " Created: " (:orcpub.user/created user))) + (do + (println "User not found:" username-or-email) + (System/exit 1)))) + +(defn list-users [db] + (let [users (d/q '[:find [(pull ?e [:orcpub.user/username + :orcpub.user/email + :orcpub.user/verified?]) ...] + :where [?e :orcpub.user/username]] + db)] + (if (empty? users) + (println "No users found.") + (do + (println (format "%-20s %-30s %s" "USERNAME" "EMAIL" "VERIFIED")) + (println (apply str (repeat 65 "-"))) + (doseq [u (sort-by :orcpub.user/username users)] + (println (format "%-20s %-30s %s" + (:orcpub.user/username u) + (:orcpub.user/email u) + (:orcpub.user/verified? u)))))))) + +;; --- CLI dispatch --- + +(let [args *command-line-args* + cmd (first args)] + (case cmd + "create" (let [[_ username email password] args] + (when-not (and username email password) + (binding [*out* *err*] + (println "Usage: manage-user.clj create ")) + (System/exit 1)) + (let [conn (get-conn)] + (create-user! conn username email password))) + + "verify" (let [[_ username-or-email] args] + (when-not username-or-email + (binding [*out* *err*] + (println "Usage: manage-user.clj verify ")) + (System/exit 1)) + (let [conn (get-conn)] + (verify-user! conn username-or-email))) + + "check" (let [[_ username-or-email] args] + (when-not username-or-email + (binding [*out* *err*] + (println "Usage: manage-user.clj check ")) + (System/exit 1)) + (let [conn (get-conn) + db (d/db conn)] + (check-user db username-or-email))) + + "list" (let [conn (get-conn) + db (d/db conn)] + (list-users db)) + + (do + (println "OrcPub User Management") + (println "") + (println "Commands:") + (println " create Create and auto-verify a user") + (println " verify Verify an existing user") + (println " check Check if a user exists") + (println " list List all users") + (when-not cmd + (System/exit 1))))) From f5df45897941f68b200e1ab8b80764a863ee1bc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 13:57:08 +0000 Subject: [PATCH 03/10] Add Docker integration test CI workflow Runs on PRs that touch Docker/deploy files. Tests the full flow: docker-setup.sh --auto, docker compose up, then exercises all docker-user.sh commands (create, check, list, verify, duplicate rejection, nonexistent user). Also lints shell scripts with shellcheck. https://claude.ai/code/session_01KdeKniaCeDDV1UvnkTLQcb --- .github/workflows/docker-integration.yml | 151 +++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 .github/workflows/docker-integration.yml diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml new file mode 100644 index 000000000..67d0475b1 --- /dev/null +++ b/.github/workflows/docker-integration.yml @@ -0,0 +1,151 @@ +name: Docker Integration Test + +on: + pull_request: + branches: [develop] + paths: + - 'docker/**' + - 'docker-compose*.yaml' + - 'docker-setup.sh' + - 'docker-user.sh' + - 'deploy/**' + - '.github/workflows/docker-integration.yml' + workflow_dispatch: + +jobs: + docker-test: + name: Docker Setup & User Management + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Lint shell scripts + run: | + sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck + shellcheck docker-setup.sh docker-user.sh + + - name: Run docker-setup.sh --auto + run: | + ./docker-setup.sh --auto + echo "--- Generated .env (secrets redacted) ---" + sed 's/=.*/=***/' .env + + - name: Validate .env password consistency + run: | + PW=$(grep '^DATOMIC_PASSWORD=' .env | cut -d= -f2) + URL_PW=$(grep '^DATOMIC_URL=' .env | sed 's/.*password=//') + if [ "$PW" != "$URL_PW" ]; then + echo "FAIL: DATOMIC_PASSWORD and DATOMIC_URL password mismatch" + exit 1 + fi + echo "OK: Passwords match" + + - name: Pull and start containers + run: | + docker compose up -d + docker compose ps + + - name: Wait for Datomic transactor + run: | + echo "Waiting for Datomic transactor to accept connections..." + for i in $(seq 1 30); do + if docker compose logs datomic 2>&1 | grep -q "System started"; then + echo "Datomic transactor is ready (after ~${i}s)" + break + fi + if [ "$i" -eq 30 ]; then + echo "FAIL: Datomic did not start within 30s" + docker compose logs datomic + exit 1 + fi + sleep 1 + done + + - name: Wait for orcpub app + run: | + echo "Waiting for orcpub app to be ready..." + for i in $(seq 1 60); do + CONTAINER=$(docker compose ps -q orcpub) + if docker exec "$CONTAINER" java -cp /orcpub.jar clojure.main -e \ + '(require (quote [datomic.api :as d])) (d/connect (System/getenv "DATOMIC_URL")) (println "ready")' \ + 2>/dev/null | grep -q "ready"; then + echo "orcpub app connected to Datomic (after ~${i}s)" + break + fi + if [ "$i" -eq 60 ]; then + echo "FAIL: orcpub could not connect to Datomic within 60s" + docker compose logs + exit 1 + fi + sleep 1 + done + + - name: Test — create user + run: | + ./docker-user.sh create testadmin admin@test.local SecurePass123 + echo "Exit code: $?" + + - name: Test — check user exists + run: | + OUTPUT=$(./docker-user.sh check testadmin) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "testadmin" + echo "$OUTPUT" | grep -q "admin@test.local" + echo "$OUTPUT" | grep -q "true" # verified + + - name: Test — list includes user + run: | + OUTPUT=$(./docker-user.sh list) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "testadmin" + + - name: Test — duplicate user fails + run: | + if ./docker-user.sh create testadmin admin@test.local SecurePass123 2>&1; then + echo "FAIL: Should have rejected duplicate user" + exit 1 + fi + echo "OK: Duplicate user correctly rejected" + + - name: Test — create second user + run: ./docker-user.sh create player2 player2@test.local AnotherPass456 + + - name: Test — list shows both users + run: | + OUTPUT=$(./docker-user.sh list) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "testadmin" + echo "$OUTPUT" | grep -q "player2" + + - name: Test — verify already-verified user is idempotent + run: | + OUTPUT=$(./docker-user.sh verify testadmin) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "already verified" + + - name: Test — check nonexistent user fails + run: | + if ./docker-user.sh check nobody@nowhere.com 2>&1; then + echo "FAIL: Should have reported user not found" + exit 1 + fi + echo "OK: Nonexistent user correctly not found" + + - name: Collect logs on failure + if: failure() + run: | + echo "=== docker compose ps ===" + docker compose ps + echo "=== datomic logs ===" + docker compose logs datomic + echo "=== orcpub logs ===" + docker compose logs orcpub + echo "=== web logs ===" + docker compose logs web + + - name: Cleanup + if: always() + run: docker compose down -v From 5398c7e8f9e7c66d40e95e2f6d8b973e05f4106b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 13:58:55 +0000 Subject: [PATCH 04/10] Add HTTP login test to Docker integration CI Tests that a user created via docker-user.sh can actually log in through the app's POST /login endpoint and receive a JWT token. Also verifies that wrong passwords are rejected with HTTP 401. https://claude.ai/code/session_01KdeKniaCeDDV1UvnkTLQcb --- .github/workflows/docker-integration.yml | 52 ++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 67d0475b1..1c22ef89f 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -134,6 +134,58 @@ jobs: fi echo "OK: Nonexistent user correctly not found" + - name: Test — created user can log in via HTTP + run: | + # Wait for the HTTP server to be listening + for i in $(seq 1 30); do + if curl -sf http://localhost:8890/ >/dev/null 2>&1 || \ + curl -sf -o /dev/null -w "%{http_code}" http://localhost:8890/ 2>&1 | grep -qE "^[234]"; then + echo "HTTP server is up (after ~${i}s)" + break + fi + if [ "$i" -eq 30 ]; then + echo "WARN: HTTP server not reachable, skipping login test" + exit 0 + fi + sleep 1 + done + + # POST /login with the user we created + RESPONSE=$(curl -sf -X POST http://localhost:8890/login \ + -H "Content-Type: application/json" \ + -d '{"username":"testadmin","password":"SecurePass123"}' \ + -w "\n%{http_code}" 2>&1) || true + + HTTP_CODE=$(echo "$RESPONSE" | tail -1) + BODY=$(echo "$RESPONSE" | sed '$d') + + echo "HTTP $HTTP_CODE" + echo "$BODY" + + if [ "$HTTP_CODE" = "200" ]; then + echo "OK: Login succeeded" + echo "$BODY" | grep -q "token" + echo "OK: Response contains JWT token" + else + echo "FAIL: Expected HTTP 200, got $HTTP_CODE" + exit 1 + fi + + - name: Test — wrong password is rejected + run: | + HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \ + -X POST http://localhost:8890/login \ + -H "Content-Type: application/json" \ + -d '{"username":"testadmin","password":"WrongPassword"}' 2>&1) || true + + echo "HTTP $HTTP_CODE" + if [ "$HTTP_CODE" = "401" ]; then + echo "OK: Wrong password correctly rejected" + else + echo "FAIL: Expected HTTP 401, got $HTTP_CODE" + exit 1 + fi + - name: Collect logs on failure if: failure() run: | From cda2b8c79281aa5d96479045f20b77b9b2d2db2d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 14:18:58 +0000 Subject: [PATCH 05/10] Fix shellcheck warnings in docker-setup.sh - Remove unused ENV_EXAMPLE variable (SC2034) - Quote expansions inside ${dir#...} parameter substitution (SC2295) https://claude.ai/code/session_01KdeKniaCeDDV1UvnkTLQcb --- docker-setup.sh | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/docker-setup.sh b/docker-setup.sh index e03c527de..b854a5030 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -18,7 +18,6 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ENV_FILE="${SCRIPT_DIR}/.env" -ENV_EXAMPLE="${SCRIPT_DIR}/.env.example" # --------------------------------------------------------------------------- # Helpers @@ -196,9 +195,9 @@ header "Directories" for dir in "${SCRIPT_DIR}/data" "${SCRIPT_DIR}/logs" "${SCRIPT_DIR}/deploy/homebrew"; do if [ ! -d "$dir" ]; then mkdir -p "$dir" - info "Created directory: ${dir#${SCRIPT_DIR}/}" + info "Created directory: ${dir#"${SCRIPT_DIR}"/}" else - info "Directory exists: ${dir#${SCRIPT_DIR}/}" + info "Directory exists: ${dir#"${SCRIPT_DIR}"/}" fi done From c313f0e58c004dca2553e419bcee5adc27310e9c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 14:32:12 +0000 Subject: [PATCH 06/10] Use Docker native healthchecks instead of JVM polling - Add healthcheck to datomic service (nc -z localhost 4334) - Add healthcheck to orcpub service (wget --spider localhost:8890) - Use depends_on condition: service_healthy for proper startup ordering - Replace JVM-per-iteration readiness loop in docker-user.sh with docker inspect health status polling (falls back to wget if no healthcheck is defined) - Replace CI wait steps with health status polling instead of spawning a new JVM every second - Fix CI HTTP tests to use nginx (port 443) since orcpub:8890 is not exposed to the host - Remove obsolete `version: '3'` from both compose files https://claude.ai/code/session_01KdeKniaCeDDV1UvnkTLQcb --- .github/workflows/docker-integration.yml | 67 +++++++++++------------- docker-compose-build.yaml | 19 +++++-- docker-compose.yaml | 19 +++++-- docker-user.sh | 45 ++++++++++++---- 4 files changed, 99 insertions(+), 51 deletions(-) diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 1c22ef89f..786acff43 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -48,41 +48,50 @@ jobs: docker compose up -d docker compose ps - - name: Wait for Datomic transactor + - name: Wait for healthy containers run: | - echo "Waiting for Datomic transactor to accept connections..." - for i in $(seq 1 30); do - if docker compose logs datomic 2>&1 | grep -q "System started"; then - echo "Datomic transactor is ready (after ~${i}s)" + echo "Waiting for datomic healthcheck..." + for i in $(seq 1 60); do + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q datomic)" 2>/dev/null || echo "waiting") + if [ "$STATUS" = "healthy" ]; then + echo "Datomic is healthy (after ~$((i * 2))s)" break fi - if [ "$i" -eq 30 ]; then - echo "FAIL: Datomic did not start within 30s" + if [ "$STATUS" = "unhealthy" ]; then + echo "FAIL: Datomic reported unhealthy" + docker compose logs datomic + exit 1 + fi + if [ "$i" -eq 60 ]; then + echo "FAIL: Datomic did not become healthy within 120s" docker compose logs datomic exit 1 fi - sleep 1 + sleep 2 done - - name: Wait for orcpub app - run: | - echo "Waiting for orcpub app to be ready..." + echo "Waiting for orcpub healthcheck..." for i in $(seq 1 60); do - CONTAINER=$(docker compose ps -q orcpub) - if docker exec "$CONTAINER" java -cp /orcpub.jar clojure.main -e \ - '(require (quote [datomic.api :as d])) (d/connect (System/getenv "DATOMIC_URL")) (println "ready")' \ - 2>/dev/null | grep -q "ready"; then - echo "orcpub app connected to Datomic (after ~${i}s)" + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q orcpub)" 2>/dev/null || echo "waiting") + if [ "$STATUS" = "healthy" ]; then + echo "orcpub is healthy (after ~$((i * 2))s)" break fi + if [ "$STATUS" = "unhealthy" ]; then + echo "FAIL: orcpub reported unhealthy" + docker compose logs + exit 1 + fi if [ "$i" -eq 60 ]; then - echo "FAIL: orcpub could not connect to Datomic within 60s" + echo "FAIL: orcpub did not become healthy within 120s" docker compose logs exit 1 fi - sleep 1 + sleep 2 done + docker compose ps + - name: Test — create user run: | ./docker-user.sh create testadmin admin@test.local SecurePass123 @@ -136,22 +145,8 @@ jobs: - name: Test — created user can log in via HTTP run: | - # Wait for the HTTP server to be listening - for i in $(seq 1 30); do - if curl -sf http://localhost:8890/ >/dev/null 2>&1 || \ - curl -sf -o /dev/null -w "%{http_code}" http://localhost:8890/ 2>&1 | grep -qE "^[234]"; then - echo "HTTP server is up (after ~${i}s)" - break - fi - if [ "$i" -eq 30 ]; then - echo "WARN: HTTP server not reachable, skipping login test" - exit 0 - fi - sleep 1 - done - - # POST /login with the user we created - RESPONSE=$(curl -sf -X POST http://localhost:8890/login \ + # Use nginx (port 443) since orcpub:8890 is not exposed to host + RESPONSE=$(curl -sk -X POST https://localhost/login \ -H "Content-Type: application/json" \ -d '{"username":"testadmin","password":"SecurePass123"}' \ -w "\n%{http_code}" 2>&1) || true @@ -173,8 +168,8 @@ jobs: - name: Test — wrong password is rejected run: | - HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \ - -X POST http://localhost:8890/login \ + HTTP_CODE=$(curl -sk -o /dev/null -w "%{http_code}" \ + -X POST https://localhost/login \ -H "Content-Type: application/json" \ -d '{"username":"testadmin","password":"WrongPassword"}' 2>&1) || true diff --git a/docker-compose-build.yaml b/docker-compose-build.yaml index 611a34958..0ca89be1d 100644 --- a/docker-compose-build.yaml +++ b/docker-compose-build.yaml @@ -1,5 +1,4 @@ --- -version: '3' services: orcpub: build: @@ -18,7 +17,14 @@ services: DATOMIC_URL: ${DATOMIC_URL:-datomic:free://datomic:4334/orcpub?password=change-me} SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} depends_on: - - datomic + datomic: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8890/"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 15s restart: always datomic: build: @@ -30,6 +36,12 @@ services: volumes: - ./data:/data - ./logs:/logs + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 4334"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s restart: always web: image: nginx:alpine @@ -41,5 +53,6 @@ services: - ./deploy/snakeoil.crt:/etc/nginx/snakeoil.crt - ./deploy/snakeoil.key:/etc/nginx/snakeoil.key depends_on: - - orcpub + orcpub: + condition: service_healthy restart: always diff --git a/docker-compose.yaml b/docker-compose.yaml index 23e242306..7b341864f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,5 +1,4 @@ --- -version: '3' services: orcpub: image: orcpub/orcpub:latest @@ -16,7 +15,14 @@ services: DATOMIC_URL: ${DATOMIC_URL:-datomic:free://datomic:4334/orcpub?password=change-me} SIGNATURE: ${SIGNATURE:-change-me-to-something-unique} depends_on: - - datomic + datomic: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8890/"] + interval: 5s + timeout: 3s + retries: 20 + start_period: 15s restart: always datomic: image: orcpub/datomic:latest @@ -26,6 +32,12 @@ services: volumes: - ./data:/data - ./logs:/logs + healthcheck: + test: ["CMD-SHELL", "nc -z localhost 4334"] + interval: 5s + timeout: 3s + retries: 10 + start_period: 10s restart: always web: image: nginx:alpine @@ -38,5 +50,6 @@ services: - ./deploy/snakeoil.key:/etc/nginx/snakeoil.key - ./deploy/homebrew/:/usr/share/nginx/html/homebrew/ depends_on: - - orcpub + orcpub: + condition: service_healthy restart: always diff --git a/docker-user.sh b/docker-user.sh index f1b4ca68e..f357b3d67 100755 --- a/docker-user.sh +++ b/docker-user.sh @@ -95,7 +95,7 @@ find_container() { wait_for_ready() { local container="$1" - local max_wait=30 + local max_wait=120 local waited=0 # Check container is running @@ -105,14 +105,41 @@ wait_for_ready() { exit 1 fi - # Wait for the app to have connected to Datomic (the uberjar starts the - # Component system which connects on boot). We test by attempting a - # trivial Datomic query via clojure.main. - printf "Waiting for Datomic connection" + # Wait for Docker's native healthcheck (defined in docker-compose.yaml) + # to report the container as healthy. This avoids spawning a JVM per check. + local health + health=$(docker inspect --format='{{if .State.Health}}yes{{end}}' "$container" 2>/dev/null || true) + + if [ "$health" = "yes" ]; then + printf "Waiting for container health check" + while [ $waited -lt $max_wait ]; do + local status + status=$(docker inspect --format='{{.State.Health.Status}}' "$container" 2>/dev/null || true) + if [ "$status" = "healthy" ]; then + echo "" + info "Container is healthy" + return 0 + fi + if [ "$status" = "unhealthy" ]; then + echo "" + error "Container reported unhealthy" + exit 1 + fi + printf "." + sleep 2 + waited=$((waited + 2)) + done + echo "" + error "Timed out waiting for healthy status (${max_wait}s)." + exit 1 + fi + + # Fallback: no healthcheck defined — check HTTP readiness directly + warn "No Docker healthcheck found; polling HTTP on container port..." + printf "Waiting for app" while [ $waited -lt $max_wait ]; do - if docker exec "$container" java -cp /orcpub.jar clojure.main -e \ - '(require (quote [datomic.api :as d])) (d/connect (System/getenv "DATOMIC_URL")) (println "ready")' \ - 2>/dev/null | grep -q "ready"; then + if docker exec "$container" wget --no-verbose --tries=1 --spider \ + "http://localhost:${PORT:-8890}/" 2>/dev/null; then echo "" return 0 fi @@ -122,7 +149,7 @@ wait_for_ready() { done echo "" - error "Timed out waiting for Datomic (${max_wait}s). Is the datomic container running?" + error "Timed out waiting for app (${max_wait}s). Is the datomic container running?" exit 1 } From c5d3f9d1a88ca041cbd6f658c2e92e97f4b09032 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 19:03:32 +0000 Subject: [PATCH 07/10] Fix Docker CI: replace nc healthcheck with bash /dev/tcp, fix shellcheck SC2059 The datomic healthcheck used `nc -z localhost 4334` but netcat is not available in the openjdk:8u242-jre base image, causing the container to always report unhealthy. Replace with bash's built-in /dev/tcp which is guaranteed available. Also increase start_period to 30s and retries to 20 to give the JVM transactor more time on CI runners. Fix shellcheck SC2059 warnings in docker-setup.sh and docker-user.sh by moving color variables out of printf format strings into %s arguments. https://claude.ai/code/session_016YeAFxbw5tP5VPa95KGLWY --- docker-compose-build.yaml | 6 +++--- docker-compose.yaml | 6 +++--- docker-setup.sh | 8 ++++---- docker-user.sh | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docker-compose-build.yaml b/docker-compose-build.yaml index 0ca89be1d..7748eb73c 100644 --- a/docker-compose-build.yaml +++ b/docker-compose-build.yaml @@ -37,11 +37,11 @@ services: - ./data:/data - ./logs:/logs healthcheck: - test: ["CMD-SHELL", "nc -z localhost 4334"] + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/4334'"] interval: 5s timeout: 3s - retries: 10 - start_period: 10s + retries: 20 + start_period: 30s restart: always web: image: nginx:alpine diff --git a/docker-compose.yaml b/docker-compose.yaml index 7b341864f..6a12614f3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,11 +33,11 @@ services: - ./data:/data - ./logs:/logs healthcheck: - test: ["CMD-SHELL", "nc -z localhost 4334"] + test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/4334'"] interval: 5s timeout: 3s - retries: 10 - start_period: 10s + retries: 20 + start_period: 30s restart: always web: image: nginx:alpine diff --git a/docker-setup.sh b/docker-setup.sh index b854a5030..05c5d42a9 100755 --- a/docker-setup.sh +++ b/docker-setup.sh @@ -29,10 +29,10 @@ color_red='\033[0;31m' color_cyan='\033[0;36m' color_reset='\033[0m' -info() { printf "${color_green}[INFO]${color_reset} %s\n" "$*"; } -warn() { printf "${color_yellow}[WARN]${color_reset} %s\n" "$*"; } -error() { printf "${color_red}[ERROR]${color_reset} %s\n" "$*" >&2; } -header() { printf "\n${color_cyan}=== %s ===${color_reset}\n\n" "$*"; } +info() { printf '%s[INFO]%s %s\n' "$color_green" "$color_reset" "$*"; } +warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } +error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } +header() { printf '\n%s=== %s ===%s\n\n' "$color_cyan" "$*" "$color_reset"; } generate_password() { # Generate a URL-safe random password (no special chars that break URLs/YAML) diff --git a/docker-user.sh b/docker-user.sh index f357b3d67..14575b867 100755 --- a/docker-user.sh +++ b/docker-user.sh @@ -29,9 +29,9 @@ color_red='\033[0;31m' color_yellow='\033[1;33m' color_reset='\033[0m' -info() { printf "${color_green}[OK]${color_reset} %s\n" "$*"; } -error() { printf "${color_red}[ERROR]${color_reset} %s\n" "$*" >&2; } -warn() { printf "${color_yellow}[WARN]${color_reset} %s\n" "$*"; } +info() { printf '%s[OK]%s %s\n' "$color_green" "$color_reset" "$*"; } +error() { printf '%s[ERROR]%s %s\n' "$color_red" "$color_reset" "$*" >&2; } +warn() { printf '%s[WARN]%s %s\n' "$color_yellow" "$color_reset" "$*"; } usage() { cat <<'USAGE' From a6fb857208694ed47d2178d350d2dfa8c98395cf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 20:11:08 +0000 Subject: [PATCH 08/10] Fix datomic healthcheck: use /proc/net/tcp instead of bash /dev/tcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous healthcheck (bash -c 'echo > /dev/tcp/localhost/4334') fails on the openjdk:8u242-jre base image because CMD-SHELL runs via /bin/sh (dash) and /dev/tcp is a bash-only feature that may not be available in slim Debian images. Replace with grep on /proc/net/tcp which checks the kernel TCP listen table directly — requires only grep (always present) and works on any Linux container. Port 4334 decimal = 10EE hex, so we grep for ':10EE ' in /proc/net/tcp with a fallback to /proc/net/tcp6 for IPv6 listeners. Also restructure the CI workflow to start datomic independently first (docker compose up -d --no-deps datomic) so the depends_on chain doesn't block. This ensures we get container logs and health state on failure instead of an opaque "dependency failed to start" error. https://claude.ai/code/session_01KkQBjzJHkceYz36K79jWri --- .github/workflows/docker-integration.yml | 60 +++++++++++++++++------- docker-compose-build.yaml | 6 +-- docker-compose.yaml | 6 +-- 3 files changed, 48 insertions(+), 24 deletions(-) diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 786acff43..86041a5e9 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -43,53 +43,77 @@ jobs: fi echo "OK: Passwords match" - - name: Pull and start containers + - name: Start datomic (no deps) run: | - docker compose up -d - docker compose ps + docker compose pull + docker compose up -d --no-deps datomic + echo "Datomic container started, waiting for health..." - - name: Wait for healthy containers + - name: Wait for datomic healthy run: | - echo "Waiting for datomic healthcheck..." - for i in $(seq 1 60); do - STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q datomic)" 2>/dev/null || echo "waiting") + for i in $(seq 1 90); do + CID=$(docker compose ps -q datomic 2>/dev/null) || true + if [ -z "$CID" ]; then + echo " [$i/90] datomic container not found yet" + sleep 2 + continue + fi + RUNNING=$(docker inspect --format='{{.State.Running}}' "$CID" 2>/dev/null || echo "false") + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$CID" 2>/dev/null || echo "starting") + echo " [$i/90] running=$RUNNING health=$STATUS" if [ "$STATUS" = "healthy" ]; then echo "Datomic is healthy (after ~$((i * 2))s)" break fi - if [ "$STATUS" = "unhealthy" ]; then - echo "FAIL: Datomic reported unhealthy" + if [ "$RUNNING" = "false" ]; then + echo "WARN: datomic container stopped — dumping logs" docker compose logs datomic - exit 1 + echo "Container will restart (restart: always), continuing to wait..." fi - if [ "$i" -eq 60 ]; then - echo "FAIL: Datomic did not become healthy within 120s" + if [ "$i" -eq 90 ]; then + echo "FAIL: Datomic did not become healthy within 180s" + echo "=== container state ===" + docker inspect --format='{{json .State}}' "$CID" | python3 -m json.tool || true + echo "=== datomic logs ===" docker compose logs datomic exit 1 fi sleep 2 done - echo "Waiting for orcpub healthcheck..." - for i in $(seq 1 60); do - STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$(docker compose ps -q orcpub)" 2>/dev/null || echo "waiting") + - name: Start orcpub and web + run: | + docker compose up -d + docker compose ps + + - name: Wait for orcpub healthy + run: | + for i in $(seq 1 90); do + CID=$(docker compose ps -q orcpub 2>/dev/null) || true + if [ -z "$CID" ]; then + echo " [$i/90] orcpub container not found yet" + sleep 2 + continue + fi + STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$CID" 2>/dev/null || echo "starting") + echo " [$i/90] orcpub health=$STATUS" if [ "$STATUS" = "healthy" ]; then echo "orcpub is healthy (after ~$((i * 2))s)" break fi if [ "$STATUS" = "unhealthy" ]; then echo "FAIL: orcpub reported unhealthy" + echo "=== all logs ===" docker compose logs exit 1 fi - if [ "$i" -eq 60 ]; then - echo "FAIL: orcpub did not become healthy within 120s" + if [ "$i" -eq 90 ]; then + echo "FAIL: orcpub did not become healthy within 180s" docker compose logs exit 1 fi sleep 2 done - docker compose ps - name: Test — create user diff --git a/docker-compose-build.yaml b/docker-compose-build.yaml index 7748eb73c..015b6daa8 100644 --- a/docker-compose-build.yaml +++ b/docker-compose-build.yaml @@ -37,11 +37,11 @@ services: - ./data:/data - ./logs:/logs healthcheck: - test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/4334'"] + test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] interval: 5s timeout: 3s - retries: 20 - start_period: 30s + retries: 30 + start_period: 40s restart: always web: image: nginx:alpine diff --git a/docker-compose.yaml b/docker-compose.yaml index 6a12614f3..08e508977 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -33,11 +33,11 @@ services: - ./data:/data - ./logs:/logs healthcheck: - test: ["CMD-SHELL", "bash -c 'echo > /dev/tcp/localhost/4334'"] + test: ["CMD-SHELL", "grep -q ':10EE ' /proc/net/tcp || grep -q ':10EE ' /proc/net/tcp6"] interval: 5s timeout: 3s - retries: 20 - start_period: 30s + retries: 30 + start_period: 40s restart: always web: image: nginx:alpine From 4e3dd9e941f7935cc914dbff902043738133d41c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 20:21:20 +0000 Subject: [PATCH 09/10] Fix manage-user.clj hanging after operation: add System/exit 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Datomic peer connection creates non-daemon background threads (heartbeats, connection pools) that keep the JVM alive indefinitely after the script finishes its work. Error paths already called System/exit 1, but success paths returned normally — leaving the JVM (and docker exec) hanging forever in CI. Add System/exit 0 after the CLI dispatch case to force clean shutdown. https://claude.ai/code/session_01KkQBjzJHkceYz36K79jWri --- docker/scripts/manage-user.clj | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docker/scripts/manage-user.clj b/docker/scripts/manage-user.clj index 1f3a1f589..96954b825 100644 --- a/docker/scripts/manage-user.clj +++ b/docker/scripts/manage-user.clj @@ -151,4 +151,6 @@ (println " check Check if a user exists") (println " list List all users") (when-not cmd - (System/exit 1))))) + (System/exit 1)))) + ;; Datomic peer threads are non-daemon and keep the JVM alive; force exit. + (System/exit 0)) From 998c8c92855dc0c1ef6c5ca7b9181fb2eb1fbb76 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 20:55:08 +0000 Subject: [PATCH 10/10] Add batch user creation: single JVM session for multiple users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `./docker-user.sh batch ` command that creates multiple users from a text file in one JVM startup (~4s) instead of one per user. File format: one user per line (username email password), with # comments and blank lines skipped. Duplicates are logged as SKIP and don't count as failures — only unexpected errors cause a non-zero exit. Refactored create-user! → try-create-user! to return result maps ({:ok true}, {:duplicate "reason"}, {:error "msg"}) instead of calling System/exit, so batch can continue past duplicates while single create still exits on any conflict. Added CI test that batch-creates 2 new users + 1 duplicate and verifies the summary counts (2 created, 1 skipped, 0 failed). https://claude.ai/code/session_01KkQBjzJHkceYz36K79jWri --- .github/workflows/docker-integration.yml | 26 ++++++ docker-user.sh | 25 +++++- docker/scripts/manage-user.clj | 106 +++++++++++++++++------ 3 files changed, 131 insertions(+), 26 deletions(-) diff --git a/.github/workflows/docker-integration.yml b/.github/workflows/docker-integration.yml index 86041a5e9..be33b3ee4 100644 --- a/.github/workflows/docker-integration.yml +++ b/.github/workflows/docker-integration.yml @@ -159,6 +159,32 @@ jobs: echo "$OUTPUT" echo "$OUTPUT" | grep -q "already verified" + - name: Test — batch create users (with duplicates) + run: | + cat > /tmp/test-users.txt <<'TXT' + # Test batch file + batch1 batch1@test.local BatchPass111 + batch2 batch2@test.local BatchPass222 + # This next line is a duplicate from earlier single-create test + testadmin admin@test.local SecurePass123 + TXT + OUTPUT=$(./docker-user.sh batch /tmp/test-users.txt) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "batch1" + echo "$OUTPUT" | grep -q "batch2" + echo "$OUTPUT" | grep -q "SKIP.*testadmin" + echo "$OUTPUT" | grep -q "2 created" + echo "$OUTPUT" | grep -q "1 skipped (duplicate)" + echo "$OUTPUT" | grep -q "0 failed" + echo "OK: Batch created 2 new, skipped 1 duplicate" + + - name: Test — batch users appear in list + run: | + OUTPUT=$(./docker-user.sh list) + echo "$OUTPUT" + echo "$OUTPUT" | grep -q "batch1" + echo "$OUTPUT" | grep -q "batch2" + - name: Test — check nonexistent user fails run: | if ./docker-user.sh check nobody@nowhere.com 2>&1; then diff --git a/docker-user.sh b/docker-user.sh index 14575b867..9605d214a 100755 --- a/docker-user.sh +++ b/docker-user.sh @@ -41,6 +41,12 @@ Usage: ./docker-user.sh create Create a new user (auto-verified, skips email) + ./docker-user.sh batch + Create multiple users from a file (one JVM startup). + File format: one user per line — username email password + Lines starting with # and blank lines are skipped. + Duplicates are logged and skipped (not treated as errors). + ./docker-user.sh verify Verify an existing unverified user @@ -56,6 +62,7 @@ Options: Examples: ./docker-user.sh create admin admin@example.com MySecurePass123 + ./docker-user.sh batch users.txt ./docker-user.sh check admin ./docker-user.sh list USAGE @@ -222,4 +229,20 @@ fi # Wait for Datomic to be reachable, then run the command wait_for_ready "$CONTAINER" -run_in_container "$CONTAINER" "$@" + +# For batch: copy the user file into the container and rewrite the path +if [ "${1:-}" = "batch" ]; then + USER_FILE="${2:-}" + if [ -z "$USER_FILE" ]; then + error "Usage: ./docker-user.sh batch " + exit 1 + fi + if [ ! -f "$USER_FILE" ]; then + error "File not found: $USER_FILE" + exit 1 + fi + docker cp "$USER_FILE" "${CONTAINER}:/tmp/batch-users.txt" + run_in_container "$CONTAINER" batch /tmp/batch-users.txt +else + run_in_container "$CONTAINER" "$@" +fi diff --git a/docker/scripts/manage-user.clj b/docker/scripts/manage-user.clj index 96954b825..b02cac2ca 100644 --- a/docker/scripts/manage-user.clj +++ b/docker/scripts/manage-user.clj @@ -5,6 +5,7 @@ ;; ;; Commands: ;; create — Create and auto-verify a user +;; batch — Create users from a file (one per line) ;; verify — Verify an existing unverified user ;; check — Check if a user exists and their status ;; list — List all users (username + email + verified) @@ -40,30 +41,69 @@ db username-or-email)) -(defn create-user! [conn username email password] - (let [db (d/db conn) - email (s/lower-case (s/trim email)) +(defn try-create-user! + "Creates a user. Returns {:ok true} on success, {:duplicate \"reason\"} if the + user/email already exists, or {:error \"message\"} on unexpected failure." + [conn username email password] + (let [db (d/db conn) + email (s/lower-case (s/trim email)) username (s/trim username)] - ;; Check for duplicates - (when (d/q '[:find ?e . :in $ ?email - :where [?e :orcpub.user/email ?email]] db email) - (binding [*out* *err*] - (println "ERROR: Email already registered:" email)) - (System/exit 1)) - (when (d/q '[:find ?e . :in $ ?username - :where [?e :orcpub.user/username ?username]] db username) - (binding [*out* *err*] - (println "ERROR: Username already taken:" username)) - (System/exit 1)) - ;; Create user — already verified, no email step needed - @(d/transact conn - [{:orcpub.user/email email - :orcpub.user/username username - :orcpub.user/password (hashers/encrypt password) - :orcpub.user/verified? true - :orcpub.user/send-updates? false - :orcpub.user/created (java.util.Date.)}]) - (println "OK: User created and verified —" username "<" email ">"))) + (cond + (d/q '[:find ?e . :in $ ?email + :where [?e :orcpub.user/email ?email]] db email) + {:duplicate (str "Email already registered: " email)} + + (d/q '[:find ?e . :in $ ?username + :where [?e :orcpub.user/username ?username]] db username) + {:duplicate (str "Username already taken: " username)} + + :else + (do + @(d/transact conn + [{:orcpub.user/email email + :orcpub.user/username username + :orcpub.user/password (hashers/encrypt password) + :orcpub.user/verified? true + :orcpub.user/send-updates? false + :orcpub.user/created (java.util.Date.)}]) + (println "OK: User created and verified —" username "<" email ">") + {:ok true})))) + +(defn batch-create-users! + "Reads a user file (one user per line: username email password) and creates + all users in a single JVM session. Blank lines and #-comments are skipped. + Duplicates are logged and skipped (not counted as failures). + Returns exit code 0 if no hard failures, 1 otherwise." + [conn path] + (let [lines (->> (s/split-lines (slurp path)) + (map s/trim) + (remove #(or (s/blank? %) (s/starts-with? % "#")))) + results (doall + (for [line lines] + (let [parts (s/split line #"\s+")] + (if (< (count parts) 3) + (do (binding [*out* *err*] + (println "SKIP: bad line (need: username email password):" line)) + {:error "bad line"}) + (let [[username email password] parts + result (try + (try-create-user! conn username email password) + (catch Exception e + {:error (.getMessage e)}))] + (when (:duplicate result) + (println "SKIP:" username "—" (:duplicate result))) + (when (:error result) + (binding [*out* *err*] + (println "FAIL:" username "—" (:error result)))) + result))))) + total (count results) + created (count (filter :ok results)) + dupes (count (filter :duplicate results)) + failed (count (filter :error results))] + (println) + (println (format "Batch complete: %d created, %d skipped (duplicate), %d failed, %d total" + created dupes failed total)) + (if (pos? failed) 1 0))) (defn verify-user! [conn username-or-email] (let [db (d/db conn) @@ -118,8 +158,23 @@ (binding [*out* *err*] (println "Usage: manage-user.clj create ")) (System/exit 1)) - (let [conn (get-conn)] - (create-user! conn username email password))) + (let [conn (get-conn) + result (try-create-user! conn username email password)] + (when-let [msg (or (:duplicate result) (:error result))] + (binding [*out* *err*] + (println "ERROR:" msg)) + (System/exit 1)))) + + "batch" (let [[_ path] args] + (when-not path + (binding [*out* *err*] + (println "Usage: manage-user.clj batch ") + (println " File format: one user per line — username email password") + (println " Lines starting with # and blank lines are skipped")) + (System/exit 1)) + (let [conn (get-conn) + exit (batch-create-users! conn path)] + (System/exit exit))) "verify" (let [[_ username-or-email] args] (when-not username-or-email @@ -147,6 +202,7 @@ (println "") (println "Commands:") (println " create Create and auto-verify a user") + (println " batch Create users from a file (one per line)") (println " verify Verify an existing user") (println " check Check if a user exists") (println " list List all users")