diff --git a/.ai-context/full.txt b/.ai-context/full.txt deleted file mode 100644 index e94a1f8..0000000 --- a/.ai-context/full.txt +++ /dev/null @@ -1,2285 +0,0 @@ -#!/usr/bin/env bash -# ============================================================================= -# core/bootstrap.sh — Actools Engine: Variable Init, Logging, Lock File -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -export ACTOOLS_VERSION="9.2" -MODE="${1:-fresh}" - -REAL_USER="${SUDO_USER:-$USER}" -REAL_HOME="$(eval echo "~$REAL_USER")" - -export ENV_FILE="$REAL_HOME/actools.env" -export STATE_FILE="$REAL_HOME/.actools-state.json" -LOCK_FILE="/tmp/actools.lock" -LOG_FILE="$REAL_HOME/actools-install.log" -LOG_DIR="$REAL_HOME/logs/install" -export INSTALL_DIR="$REAL_HOME" -export PKG_DONE_FLAG="/var/lib/actools/.packages_done" - -R='\033[0;31m'; G='\033[0;32m'; Y='\033[1;33m'; C='\033[0;36m'; NC='\033[0m' - -# Logging -log() { echo -e "${G}[INFO ]${NC} $(date '+%F %T') $*"; } -warn() { echo -e "${Y}[WARN ]${NC} $(date '+%F %T') $*"; } -error() { echo -e "${R}[ERROR]${NC} $(date '+%F %T') $*"; exit 1; } -section() { - echo -e "\n${C}══════════════════════════════════════════════════${NC}" - echo -e "${C} $*${NC}" - echo -e "${C}══════════════════════════════════════════════════${NC}" -} - -# Per-run log setup -mkdir -p "$LOG_DIR" 2>/dev/null || true -RUN_LOG="$LOG_DIR/actools-$(date +%F_%H%M%S).log" -exec > >(tee -a "$LOG_FILE" | tee -a "$RUN_LOG") 2>&1 - -# Dry-run mode -DRY_RUN=false -[[ "$MODE" == "dry-run" ]] && DRY_RUN=true -dryrun() { "$DRY_RUN" && { echo -e "${Y}[DRY-RUN]${NC} Would run: $*"; return 0; } || "$@"; } - -# Lock file — prevents concurrent installs -touch "$LOCK_FILE" 2>/dev/null || true -exec 200>"$LOCK_FILE" -flock -n 200 || error "Another actools installation is already running." -#!/usr/bin/env bash -# ============================================================================= -# core/secrets.sh — Actools Engine: Secret Generation and Writeback -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -rand_pass() { openssl rand -base64 18 | tr -dc 'A-Za-z0-9' | head -c 22; } - -gen_if_empty() { - local var="$1" - local val="${!var:-}" - [[ "$val" == *"CHANGEME"* ]] && error "$var contains 'CHANGEME' -- set a real value." - if [[ -z "$val" ]]; then - log "$var empty -- auto-generating..." - printf -v "$var" '%s' "$(rand_pass)" - log "$var generated." - fi -} - -writeback_secrets() { - for var in DB_ROOT_PASS DRUPAL_ADMIN_PASS; do - local val="${!var}" - if grep -qP "^${var}=\\s*(#.*)?$" "$ENV_FILE" 2>/dev/null; then - sed -i "s|^${var}=.*|${var}=${val}|" "$ENV_FILE" - log "${var} written back to env file." - fi - done -} -#!/usr/bin/env bash -# ============================================================================= -# core/state.sh — Actools Engine: State Management -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -init_state() { - [[ -f "$STATE_FILE" ]] || echo '{"envs":{},"db_passes":{}}' > "$STATE_FILE" - chown "$REAL_USER:$REAL_USER" "$STATE_FILE" 2>/dev/null || true -} - -set_state() { local tmp; tmp=$(mktemp); jq "$1" "$STATE_FILE" > "$tmp" && mv "$tmp" "$STATE_FILE"; } -get_state() { jq -r "$1" "$STATE_FILE" 2>/dev/null || echo "null"; } -is_installed() { jq -e ".envs.$1 == true" "$STATE_FILE" >/dev/null 2>&1; } -mark_installed() { set_state ".envs.$1=true"; } - -get_db_pass() { - local env="$1" pass - pass=$(get_state ".db_passes.${env}") - if [[ "$pass" == "null" || -z "$pass" ]]; then - pass=$(rand_pass) - set_state ".db_passes.${env}=\"${pass}\"" - fi - echo "$pass" -} - -get_backup_pass() { - local pass - pass=$(get_state ".backup_user_pass") - if [[ "$pass" == "null" || -z "$pass" ]]; then - pass=$(rand_pass) - set_state ".backup_user_pass=\"${pass}\"" - fi - echo "$pass" -} -#!/usr/bin/env bash -# ============================================================================= -# core/validate.sh — Actools Engine: All Validation Logic -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -validate_env() { - [[ "${PHP_MEMORY_LIMIT:-512m}" =~ ^[0-9]+[mg]$ ]] || \ - error "PHP_MEMORY_LIMIT format invalid ('${PHP_MEMORY_LIMIT}'). Use: 512m or 2g" - [[ "${WORKER_MEMORY_LIMIT:-2g}" =~ ^[0-9]+[mg]$ ]] || \ - error "WORKER_MEMORY_LIMIT format invalid ('${WORKER_MEMORY_LIMIT}'). Use: 2g or 1024m" - [[ "${DB_MEMORY_LIMIT:-2g}" =~ ^[0-9]+[mg]$ ]] || \ - error "DB_MEMORY_LIMIT format invalid ('${DB_MEMORY_LIMIT}'). Use: 2g or 1024m" - [[ "${PHP_VERSION:-8.3}" =~ ^[0-9]+\.[0-9]+$ ]] || \ - error "PHP_VERSION format invalid: '${PHP_VERSION}'. Expected e.g. 8.3" - log ".env validation passed." -} - -detect_s3_provider() { - STORAGE_PROVIDER="${STORAGE_PROVIDER:-${S3_PROVIDER:-}}" - S3_ENDPOINT_URL="${S3_ENDPOINT_URL:-${S3_ENDPOINT:-}}" - ASSET_CDN_HOST="${ASSET_CDN_HOST:-${CLOUDFLARE_CDN_DOMAIN:-}}" - - if [[ -z "$STORAGE_PROVIDER" && -n "$S3_ENDPOINT_URL" ]]; then - if [[ "$S3_ENDPOINT_URL" == *"backblazeb2.com"* ]]; then - STORAGE_PROVIDER="backblaze" - log "S3 provider auto-detected: backblaze" - elif [[ "$S3_ENDPOINT_URL" == *"wasabisys.com"* ]]; then - STORAGE_PROVIDER="wasabi" - log "S3 provider auto-detected: wasabi" - elif [[ "$S3_ENDPOINT_URL" == *"amazonaws.com"* ]]; then - STORAGE_PROVIDER="aws" - log "S3 provider auto-detected: aws" - else - STORAGE_PROVIDER="custom" - log "S3 provider auto-detected: custom" - fi - elif [[ -z "$STORAGE_PROVIDER" ]]; then - STORAGE_PROVIDER="aws" - fi -} - -validate_s3() { - if [[ "${ENABLE_S3_STORAGE:-false}" == "true" ]]; then - [[ -z "${AWS_ACCESS_KEY_ID:-}" ]] && error "ENABLE_S3_STORAGE=true but AWS_ACCESS_KEY_ID not set" - [[ -z "${AWS_SECRET_ACCESS_KEY:-}" ]] && error "ENABLE_S3_STORAGE=true but AWS_SECRET_ACCESS_KEY not set" - [[ -z "${S3_BUCKET:-}" ]] && error "ENABLE_S3_STORAGE=true but S3_BUCKET not set" - case "$STORAGE_PROVIDER" in - aws) - [[ -z "${AWS_REGION:-}" ]] && error "STORAGE_PROVIDER=aws but AWS_REGION not set" - log "S3: provider=AWS bucket=${S3_BUCKET} region=${AWS_REGION}" - ;; - backblaze) - [[ -z "$S3_ENDPOINT_URL" ]] && error "STORAGE_PROVIDER=backblaze but S3_ENDPOINT_URL not set" - log "S3: provider=Backblaze B2 bucket=${S3_BUCKET} endpoint=${S3_ENDPOINT_URL}" - ;; - wasabi) - [[ -z "$S3_ENDPOINT_URL" ]] && error "STORAGE_PROVIDER=wasabi but S3_ENDPOINT_URL not set" - log "S3: provider=Wasabi bucket=${S3_BUCKET} endpoint=${S3_ENDPOINT_URL}" - ;; - custom) - [[ -z "$S3_ENDPOINT_URL" ]] && error "STORAGE_PROVIDER=custom but S3_ENDPOINT_URL not set" - log "S3: provider=custom bucket=${S3_BUCKET} endpoint=${S3_ENDPOINT_URL}" - ;; - *) - error "STORAGE_PROVIDER must be: aws | backblaze | wasabi | custom (got: ${STORAGE_PROVIDER})" - ;; - esac - fi -} - -validate_xelatex() { - XELATEX_MODE="${XELATEX_MODE:-local}" - if [[ "$XELATEX_MODE" == "remote" ]]; then - [[ -z "${XELATEX_ENDPOINT:-}" ]] && error "XELATEX_MODE=remote but XELATEX_ENDPOINT not set" - log "XeLaTeX mode: remote (${XELATEX_ENDPOINT})" - else - log "XeLaTeX mode: local (self-contained in worker container)" - fi -} - -validate_environment_mode() { - ENV_MODE="${ENVIRONMENT_MODE:-production-isolated}" - if [[ "$ENV_MODE" == "production-isolated" ]]; then - ENVIRONMENTS="prod" - log "Mode: production-isolated (prod only)" - elif [[ "$ENV_MODE" == "all-in-one" ]]; then - ENVIRONMENTS="${ENVIRONMENTS:-dev,stg,prod}" - log "Mode: all-in-one (${ENVIRONMENTS})" - else - ENVIRONMENTS="prod" - warn "ENVIRONMENT_MODE '${ENV_MODE}' unrecognised -- defaulting to production-isolated" - fi -} - -validate_disk() { - AVAILABLE_KB=$(df / | awk 'NR==2 {print $4}') - (( AVAILABLE_KB < 20971520 )) && \ - error "Only $(( AVAILABLE_KB / 1048576 ))GB free. At least 20GB required." - log "Disk OK -- $(( AVAILABLE_KB / 1048576 ))GB free." - DISK_USE=$(df / | awk 'NR==2 {print $5}' | tr -d '%') - (( DISK_USE > 80 )) && warn "Disk ${DISK_USE}% full -- risk of failure during install." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/ai/assistant.sh — Phase 4: AI-Native Dev Environment -# Uses Ollama + deepseek-coder with codebase context -# ============================================================================= - -INSTALL_DIR="${INSTALL_DIR:-/home/actools}" -OLLAMA_URL="http://localhost:11434" -AI_MODEL="deepseek-coder:1.3b" -AI_CONTEXT_DIR="${INSTALL_DIR}/.ai-context" - -# Build context from actual codebase -build_context() { - local target="${1:-all}" - mkdir -p "$AI_CONTEXT_DIR" - - echo "Building AI context from codebase..." - - case "$target" in - core) - cat "${INSTALL_DIR}"/core/*.sh > "${AI_CONTEXT_DIR}/core.txt" 2>/dev/null - ;; - modules) - find "${INSTALL_DIR}/modules" -name "*.sh" -exec cat {} \; \ - > "${AI_CONTEXT_DIR}/modules.txt" 2>/dev/null - ;; - all|*) - cat "${INSTALL_DIR}"/core/*.sh \ - "${INSTALL_DIR}"/modules/**/*.sh \ - "${INSTALL_DIR}"/cli/commands/*.sh \ - > "${AI_CONTEXT_DIR}/full.txt" 2>/dev/null - ;; - esac - - local size - size=$(wc -l "${AI_CONTEXT_DIR}/full.txt" 2>/dev/null | awk '{print $1}') - echo " ✓ Context built: ${size} lines of code indexed" -} - -# Ask AI with codebase context -ai_ask() { - local question="$1" - local context_file="${AI_CONTEXT_DIR}/full.txt" - - if [[ ! -f "$context_file" ]]; then - build_context all - fi - - # Build prompt with codebase context - local context - context=$(cat "$context_file" 2>/dev/null | head -500) - - local prompt="You are an expert in Drupal 11, Bash scripting, Docker, and MariaDB. -You are helping with the Actools platform — a modular Drupal 11 installer. - -Here is the relevant codebase context: -\`\`\`bash -${context} -\`\`\` - -Question: ${question} - -Give a specific, practical answer based on the actual code above." - - echo "" - echo "=== Actools AI Assistant ===" - echo "Model: ${AI_MODEL}" - echo "" - - curl -s "${OLLAMA_URL}/api/generate" \ - -H "Content-Type: application/json" \ - -d "{ - \"model\": \"${AI_MODEL}\", - \"prompt\": $(echo "$prompt" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))"), - \"stream\": false, - \"options\": { - \"temperature\": 0.3, - \"num_predict\": 512 - } - }" 2>/dev/null | python3 -c " -import sys, json -data = json.load(sys.stdin) -print(data.get('response', 'No response')) -" - echo "" -} - -# Explain a specific file -ai_explain() { - local file="$1" - local full_path="${INSTALL_DIR}/${file}" - - if [[ ! -f "$full_path" ]]; then - echo "File not found: ${full_path}" - exit 1 - fi - - local code - code=$(cat "$full_path") - - echo "" - echo "=== AI Explain: ${file} ===" - echo "" - - curl -s "${OLLAMA_URL}/api/generate" \ - -H "Content-Type: application/json" \ - -d "{ - \"model\": \"${AI_MODEL}\", - \"prompt\": $(echo "Explain this bash script concisely. What does it do, what are its main functions, and are there any potential issues?\n\n\`\`\`bash\n${code}\n\`\`\`" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))"), - \"stream\": false, - \"options\": {\"temperature\": 0.2, \"num_predict\": 400} - }" 2>/dev/null | python3 -c " -import sys, json -data = json.load(sys.stdin) -print(data.get('response', 'No response')) -" - echo "" -} - -# Security review -ai_review() { - local target="${1:-core}" - local files - - case "$target" in - --security) - files=$(find "${INSTALL_DIR}/core" "${INSTALL_DIR}/modules" \ - -name "*.sh" | head -5 | xargs cat 2>/dev/null | head -300) - local review_type="security vulnerabilities, injection risks, and hardcoded secrets" - ;; - --performance) - files=$(cat "${INSTALL_DIR}/modules/db/"*.sh 2>/dev/null) - local review_type="performance issues, inefficient queries, and optimization opportunities" - ;; - *) - files=$(cat "${INSTALL_DIR}/core/"*.sh 2>/dev/null) - local review_type="code quality, best practices, and potential bugs" - ;; - esac - - echo "" - echo "=== AI Code Review: ${target} ===" - echo "" - - curl -s "${OLLAMA_URL}/api/generate" \ - -H "Content-Type: application/json" \ - -d "{ - \"model\": \"${AI_MODEL}\", - \"prompt\": $(echo "Review this bash code for ${review_type}. List specific issues found with line references where possible.\n\n\`\`\`bash\n${files}\n\`\`\`" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))"), - \"stream\": false, - \"options\": {\"temperature\": 0.2, \"num_predict\": 500} - }" 2>/dev/null | python3 -c " -import sys, json -data = json.load(sys.stdin) -print(data.get('response', 'No response')) -" - echo "" -} - -# Check Ollama is running -check_ollama() { - curl -s "${OLLAMA_URL}/api/tags" &>/dev/null || { - echo "Ollama not running. Starting..." - ollama serve &>/dev/null & - sleep 3 - } -} -#!/usr/bin/env bash -# ============================================================================= -# modules/db/backup_user.sh — Backup DB User Setup -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -setup_backup_db_user() { - local backup_pass="$1" - wait_db - docker compose exec -T db mariadb -uroot -p"${DB_ROOT_PASS}" </dev/null 2>&1 \ - || error "Cannot authenticate to MariaDB with current DB_ROOT_PASS. - Check DB_ROOT_PASS in actools.env matches the running container." - log "DB credentials verified." -} - -create_db_and_user() { - local env="$1" - local db_name="actools_${env}" - local db_pass - db_pass=$(get_db_pass "$env") - - docker compose exec -T db mariadb -uroot -p"${DB_ROOT_PASS}" </dev/null 2>&1; do - _tries=$(( _tries + 1 )) - [[ $_tries -ge 50 ]] && error "MariaDB did not become ready within 150s." - sleep 3 - done - log "MariaDB ready." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/drupal/prepare.sh — Stage 1: Database + Filesystem -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -drupal_prepare() { - local env="$1" - - - section "Stage 1: Prepare — ${env}" - - wait_db - create_db_and_user "$env" - - mkdir -p "${INSTALL_DIR}/docroot/${env}" - mkdir -p "${INSTALL_DIR}/logs/php_${env}" - - # Pre-create DB log dir with correct ownership for MariaDB (UID 999) - chown -R 999:999 "${INSTALL_DIR}/logs/db" 2>/dev/null || true - touch "${INSTALL_DIR}/logs/db/slow.log" 2>/dev/null || true - chown 999:999 "${INSTALL_DIR}/logs/db/slow.log" 2>/dev/null || true - chmod 664 "${INSTALL_DIR}/logs/db/slow.log" 2>/dev/null || true - - log "Stage 1 complete: database and filesystem ready for ${env}." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/drupal/provision.sh — Stage 2: Composer + Drupal Site Install -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -drupal_provision() { - local env="$1" - local db_name="actools_${env}" - local db_pass - db_pass=$(get_db_pass "$env") - - section "Stage 2: Provision — ${env}" - - # Install mysql client for drush DB operations - docker compose exec -T "php_${env}" bash -c \ - "apt-get update -qq && apt-get install -y -qq default-mysql-client 2>/dev/null || true" \ - 2>/dev/null || true - - # Composer + Drupal - log "Composing Drupal ${DRUPAL_VERSION} for ${env}..." - docker compose exec -T "php_${env}" bash -c " - export COMPOSER_PROCESS_TIMEOUT=${COMPOSER_PROCESS_TIMEOUT:-600} - set -euo pipefail - mkdir -p /var/www/html/${env} - cd /var/www/html/${env} - - if ! command -v composer &>/dev/null; then - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - fi - - if [[ ! -f composer.json ]]; then - composer create-project drupal/recommended-project:^${DRUPAL_VERSION} . --no-interaction - composer require drush/drush --no-interaction - fi - - EXTRA='${EXTRA_PACKAGES:-}' - [[ -n \"\$EXTRA\" ]] && composer require \$EXTRA --no-interaction || true - " - - # Drush site install - log "drush site:install for ${env}..." - docker compose exec -T "php_${env}" bash -c " - set -euo pipefail - cd /var/www/html/${env} - ./vendor/bin/drush site:install standard \ - --db-url=mysql://${db_name}:${db_pass}@db/${db_name} \ - --account-name=${DRUPAL_ADMIN_USER:-admin} \ - --account-pass=${DRUPAL_ADMIN_PASS} \ - --account-mail=${DRUPAL_ADMIN_EMAIL} \ - --site-name='AcTools ${env^}' \ - --yes - ./vendor/bin/drush cr - " - - log "Stage 2 complete: Drupal provisioned for ${env}." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/drupal/secure.sh — Stage 3: trusted_hosts + S3 + FPM + Ownership -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -drupal_secure() { - local env="$1" - - section "Stage 3: Secure — ${env}" - - # Inject trusted_host_patterns - local domain_escaped="${BASE_DOMAIN//./\\.}" - docker compose exec -T "php_${env}" bash -c " - cd /var/www/html/${env} - ./vendor/bin/drush php:eval \" - \\\$config_file = DRUPAL_ROOT . '/../sites/default/settings.php'; - \\\$trusted = ['^${domain_escaped}$', '^.*\\.${domain_escaped}$']; - \\\$line = \\\"\\\\\\\$settings['trusted_host_patterns'] = \" . var_export(\\\$trusted, true) . \";\\\"; - file_put_contents(\\\$config_file, PHP_EOL . \\\$line, FILE_APPEND); - \" 2>/dev/null || true - " 2>/dev/null || warn "trusted_host_patterns injection failed for ${env} -- set manually in settings.php" - - # S3 settings injection - if [[ "${ENABLE_S3_STORAGE:-false}" == "true" ]]; then - log "Injecting S3 credentials into settings.php for ${env}..." - docker compose exec -T "php_${env}" bash -c " - CONFIG_FILE=/var/www/html/${env}/sites/default/settings.php - cat >> \"\$CONFIG_FILE\" <<'SETTINGS' - -// S3FS configuration -- injected by actools installer (v9.2) -\$config['s3fs.settings']['access_key'] = getenv('AWS_ACCESS_KEY_ID') ?: ''; -\$config['s3fs.settings']['secret_key'] = getenv('AWS_SECRET_ACCESS_KEY') ?: ''; -\$config['s3fs.settings']['bucket'] = getenv('S3_BUCKET') ?: ''; -\$config['s3fs.settings']['region'] = getenv('AWS_REGION') ?: 'us-east-1'; -\$config['s3fs.settings']['use_s3_for_public'] = TRUE; -\$config['s3fs.settings']['use_s3_for_private'] = TRUE; - -\$_s3_endpoint = getenv('S3_ENDPOINT_URL'); -if (!empty(\$_s3_endpoint)) { - \$config['s3fs.settings']['use_customhost'] = TRUE; - \$config['s3fs.settings']['hostname'] = \$_s3_endpoint; -} - -\$_cdn_host = getenv('ASSET_CDN_HOST'); -if (!empty(\$_cdn_host)) { - \$config['s3fs.settings']['use_cname'] = TRUE; - \$config['s3fs.settings']['domain'] = \$_cdn_host; -} -SETTINGS - " 2>/dev/null || warn "S3 settings.php injection failed for ${env}" - log "S3 settings injected for ${env}." - fi - - # PHP-FPM slow log - docker compose exec -T "php_${env}" bash -c " - if [[ -f /usr/local/etc/php-fpm.d/www.conf ]]; then - echo 'slowlog = /var/log/php/www-slow.log' >> /usr/local/etc/php-fpm.d/www.conf - echo 'request_slowlog_timeout = 5s' >> /usr/local/etc/php-fpm.d/www.conf - kill -USR2 1 2>/dev/null || true - fi - " 2>/dev/null || true - - # File ownership - docker compose exec -T "php_${env}" bash -c " - chown -R www-data:www-data /var/www/html/${env}/web/sites/default/files 2>/dev/null || true - " 2>/dev/null || true - chown -R "$REAL_USER:$REAL_USER" "${INSTALL_DIR}/docroot/${env}" 2>/dev/null || true - - # Save credentials - mark_installed "$env" - local db_pass - db_pass=$(get_db_pass "$env") - set_state ".db_passes.${env}=\"${db_pass}\"" - echo "[${env}] DB: actools_${env} User: actools_${env} Pass: ${db_pass}" \ - >> "$REAL_HOME/.actools-db-creds" - chmod 600 "$REAL_HOME/.actools-db-creds" 2>/dev/null || true - - log "Stage 3 complete: ${env} secured and hardened." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/health/checks.sh — Phase 2: Semantic Health Checks -# ============================================================================= - -health_check_all() { - local issues=0 - - echo "" - echo "=== Actools Health Check ===" - echo "$(date '+%F %T')" - echo "" - - # --- Container status --- - echo "── Containers ──────────────────────────" - local containers=("actools_caddy" "actools_db" "actools_php_prod" "actools_redis" "actools_worker_prod") - for c in "${containers[@]}"; do - local status health - health=$(docker inspect "$c" --format="{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}" 2>/dev/null || echo "none") - status=$(docker inspect "$c" --format='{{.State.Status}}' 2>/dev/null || echo "missing") - - - if [[ "$status" == "running" ]]; then - if [[ "$health" == "healthy" || "$health" == "none" || "$health" == "" ]]; then - echo " ✓ ${c} — ${status}" - else - echo " ✗ ${c} — ${status} (health: ${health})" - (( issues++ )) || true - fi - else - echo " ✗ ${c} — ${status}" - (( issues++ )) || true - fi - done - - # --- Memory pressure --- - echo "" - echo "── Memory Pressure ─────────────────────" - local containers_with_limits=("actools_php_prod:512" "actools_db:2048" "actools_redis:256") - for entry in "${containers_with_limits[@]}"; do - local name="${entry%%:*}" - local limit_mib="${entry##*:}" - local mem_raw - mem_raw=$(docker stats --no-stream --format "{{.MemUsage}}" "$name" 2>/dev/null \ - | grep -oP '^[\d.]+(?=MiB)' || echo "0") - if [[ -n "$mem_raw" && "$mem_raw" != "0" ]]; then - local mem_int="${mem_raw%.*}" - local pct=$(( mem_int * 100 / limit_mib )) - if (( pct > 85 )); then - echo " ✗ ${name}: ${mem_raw}MiB / ${limit_mib}MiB (${pct}%) — CRITICAL" - (( issues++ )) || true - elif (( pct > 70 )); then - echo " ! ${name}: ${mem_raw}MiB / ${limit_mib}MiB (${pct}%) — WARNING" - else - echo " ✓ ${name}: ${mem_raw}MiB / ${limit_mib}MiB (${pct}%)" - fi - fi - done - - # --- TLS certificate expiry --- - echo "" - echo "── TLS Certificate ─────────────────────" - local expiry expiry_epoch now_epoch days_left - expiry=$(echo | openssl s_client -connect "${BASE_DOMAIN}:443" \ - -servername "${BASE_DOMAIN}" 2>/dev/null \ - | openssl x509 -noout -enddate 2>/dev/null \ - | cut -d= -f2) - if [[ -n "$expiry" ]]; then - expiry_epoch=$(date -d "$expiry" +%s 2>/dev/null) - now_epoch=$(date +%s) - days_left=$(( (expiry_epoch - now_epoch) / 86400 )) - if (( days_left < 14 )); then - echo " ✗ ${BASE_DOMAIN} — expires in ${days_left} days — RENEW NOW" - (( issues++ )) || true - else - echo " ✓ ${BASE_DOMAIN} — expires in ${days_left} days" - fi - else - echo " ! TLS check unavailable" - fi - - # --- Disk space --- - echo "" - echo "── Disk Space ──────────────────────────" - local disk_pct disk_free - disk_pct=$(df / | awk 'NR==2 {print $5}' | tr -d '%') - disk_free=$(df -h / | awk 'NR==2 {print $4}') - if (( disk_pct > 85 )); then - echo " ✗ Disk: ${disk_pct}% used (${disk_free} free) — CRITICAL" - (( issues++ )) || true - elif (( disk_pct > 70 )); then - echo " ! Disk: ${disk_pct}% used (${disk_free} free) — WARNING" - else - echo " ✓ Disk: ${disk_pct}% used (${disk_free} free)" - fi - - # --- MariaDB slow queries --- - echo "" - echo "── MariaDB ─────────────────────────────" - local slow_queries - slow_queries=$(docker exec actools_db mariadb -uroot -p"${DB_ROOT_PASS}" -sN \ - -e "SHOW GLOBAL STATUS LIKE 'Slow_queries';" 2>/dev/null | awk '{print $2}') - if [[ -n "$slow_queries" ]]; then - if (( slow_queries > 100 )); then - echo " ! Slow queries: ${slow_queries} — check slow query log" - else - echo " ✓ Slow queries: ${slow_queries}" - fi - fi - - # --- Redis eviction --- - echo "" - echo "── Redis ───────────────────────────────" - local evicted - evicted=$(docker exec actools_redis redis-cli info stats 2>/dev/null \ - | grep evicted_keys | cut -d: -f2 | tr -d '[:space:]') - if [[ -n "$evicted" && "$evicted" -gt 0 ]]; then - echo " ! Redis evicted keys: ${evicted} — consider increasing REDIS_MEMORY_LIMIT" - else - echo " ✓ Redis evictions: 0" - fi - - # --- Summary --- - echo "" - echo "────────────────────────────────────────" - if (( issues == 0 )); then - echo " ✓ All checks passed — system healthy" - else - echo " ✗ ${issues} issue(s) found — review above" - [[ -n "${NOTIFY_WEBHOOK:-}" ]] && \ - curl -fsS -X POST "${NOTIFY_WEBHOOK}" \ - -H "Content-Type: application/json" \ - -d "{\"text\":\"Actools health: ${issues} issue(s) on ${BASE_DOMAIN}\"}" \ - --max-time 10 &>/dev/null || true - fi - echo "" -} -#!/usr/bin/env bash -# ============================================================================= -# modules/host/docker.sh — Docker CE Installation -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -install_docker() { - section "Docker Engine" - if ! command -v docker &>/dev/null; then - install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg \ - | gpg --dearmor -o /etc/apt/keyrings/docker.gpg - chmod a+r /etc/apt/keyrings/docker.gpg - echo \ - "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ - https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \ - > /etc/apt/sources.list.d/docker.list - apt-get update -qq - apt-get install -y -qq docker-ce docker-ce-cli containerd.io \ - docker-buildx-plugin docker-compose-plugin - usermod -aG docker "$REAL_USER" - log "Docker CE installed." - else - log "Docker present: $(docker --version)" - fi - - if [[ ! -f /etc/docker/daemon.json ]]; then - cat > /etc/docker/daemon.json </dev/null || true - log "Docker daemon log rotation configured." - fi - - ! docker compose version &>/dev/null && apt-get install -y -qq docker-compose-plugin - systemctl enable --now docker - log "Docker Compose: $(docker compose version)" -} -#!/usr/bin/env bash -# ============================================================================= -# modules/host/firewall.sh — UFW + Fail2ban -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -configure_firewall() { - section "Firewall" - ufw limit 22/tcp comment 'SSH rate-limited' 2>/dev/null || true - ufw allow 80/tcp comment 'HTTP Caddy ACME' 2>/dev/null || true - ufw allow 443/tcp comment 'HTTPS' 2>/dev/null || true - ufw allow 443/udp comment 'HTTP/3 QUIC' 2>/dev/null || true - ufw --force enable - systemctl enable --now fail2ban - log "UFW + fail2ban active." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/host/kernel.sh — Kernel Tuning -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -tune_kernel() { - section "Kernel Tuning" - cat > /etc/sysctl.d/99-actools.conf </dev/null 2>&1 - log "Kernel tuning applied." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/host/logrotate.sh — Host Log Rotation -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -configure_logrotate() { - cat > /etc/logrotate.d/actools <> /etc/fstab - log "Swap active: ${SWAP}." - else - log "Swap already configured -- skipping." - fi - else - warn "Swap disabled. XeLaTeX in worker container may OOM on large papers." - fi -} -#!/usr/bin/env bash -# ============================================================================= -# modules/migrate/migrate.sh — Phase 3: Zero-Downtime DB Migrations -# Uses gh-ost for large tables (>100k rows), drush updb for smaller ones -# ============================================================================= - -INSTALL_DIR="${INSTALL_DIR:-/home/actools}" -MIGRATE_LOG="${INSTALL_DIR}/logs/migrate" - -migrate_plan() { - local env="${1:-prod}" - local db_name="actools_${env}" - - echo "" - echo "=== Migration Plan: ${env} ===" - echo "Database: ${db_name}" - echo "" - - # Check pending Drupal updates - echo "── Pending Drupal Updates ──────────────────" - cd "$INSTALL_DIR" - local pending - pending=$(docker compose exec -T "php_${env}" bash -c \ - "cd /var/www/html/${env} && ./vendor/bin/drush updatedb:status 2>/dev/null" \ - 2>/dev/null || echo "Could not check pending updates") - - if echo "$pending" | grep -q "No database updates"; then - echo " ✓ No pending database updates" - else - echo "$pending" | head -20 - fi - - echo "" - echo "── Large Tables (>100k rows — will use gh-ost) ──" - docker compose exec -T db mariadb -uroot -p"${DB_ROOT_PASS}" -sN </dev/null -SELECT - table_name, - table_rows, - ROUND(data_length/1024/1024, 2) AS size_mb -FROM information_schema.tables -WHERE table_schema = '${db_name}' - AND table_rows > 100000 -ORDER BY table_rows DESC; -SQL - - echo "" - echo "── All Tables ──────────────────────────────" - docker compose exec -T db mariadb -uroot -p"${DB_ROOT_PASS}" -sN </dev/null -SELECT - table_name, - table_rows, - ROUND(data_length/1024/1024, 2) AS size_mb -FROM information_schema.tables -WHERE table_schema = '${db_name}' -ORDER BY table_rows DESC -LIMIT 20; -SQL - - echo "" - echo "Run 'actools migrate --apply ${env}' to apply pending updates" - echo "Run 'actools migrate --rollback ${env}' to rollback last migration" -} - -migrate_apply() { - local env="${1:-prod}" - local db_name="actools_${env}" - - mkdir -p "$MIGRATE_LOG" - local log_file="${MIGRATE_LOG}/migrate_${env}_$(date +%F_%H%M%S).log" - - echo "" - echo "=== Applying Migrations: ${env} ===" - echo "Log: ${log_file}" - echo "" - - cd "$INSTALL_DIR" - - # Step 1: Pre-migration backup - echo "Step 1/4: Pre-migration backup..." - local snap="${INSTALL_DIR}/backups/pre_migrate_${env}_$(date +%F_%H%M%S).sql.gz" - docker compose exec -T db mariadb-dump \ - -uroot -p"${DB_ROOT_PASS}" \ - --single-transaction --quick \ - "${db_name}" | gzip > "$snap" - echo " ✓ Snapshot: ${snap}" - - # Step 2: Check for large tables needing gh-ost - echo "" - echo "Step 2/4: Checking table sizes..." - local large_tables - large_tables=$(docker compose exec -T db mariadb -uroot -p"${DB_ROOT_PASS}" -sN </dev/null -SELECT table_name FROM information_schema.tables -WHERE table_schema = '${db_name}' AND table_rows > 100000; -SQL -) - - if [[ -n "$large_tables" ]]; then - echo " ! Large tables detected — gh-ost will be used for schema changes:" - echo "$large_tables" | while read -r t; do echo " - $t"; done - else - echo " ✓ No large tables — standard drush updb will be used" - fi - - # Step 3: Run drush updb - echo "" - echo "Step 3/4: Running drush updatedb..." - docker compose exec -T "php_${env}" bash -c " - cd /var/www/html/${env} - ./vendor/bin/drush updatedb --yes 2>&1 - ./vendor/bin/drush cr 2>&1 - " | tee -a "$log_file" - - # Step 4: Post-migration health check - echo "" - echo "Step 4/4: Post-migration health check..." - local status - status=$(curl -sso /dev/null -w "%{http_code}" --max-time 15 \ - "https://${BASE_DOMAIN}" 2>/dev/null || echo "ERR") - - if [[ "$status" == "200" ]]; then - echo " ✓ Site responding: HTTP ${status}" - echo "" - echo "=== Migration complete ===" - echo " Backup: ${snap}" - echo " Log: ${log_file}" - else - echo " ✗ Site not responding: HTTP ${status}" - echo "" - echo " ROLLBACK: actools migrate --rollback ${env}" - echo " Backup available: ${snap}" - exit 1 - fi -} - -migrate_rollback() { - local env="${1:-prod}" - local db_name="actools_${env}" - - echo "" - echo "=== Rollback: ${env} ===" - - # Find latest pre-migrate backup - local latest - latest=$(ls -t "${INSTALL_DIR}/backups/pre_migrate_${env}_"*.sql.gz 2>/dev/null | head -1) - - if [[ -z "$latest" ]]; then - echo "No pre-migration backup found." - echo "Available backups:" - ls -lht "${INSTALL_DIR}/backups/"*.sql.gz 2>/dev/null | head -5 - exit 1 - fi - - echo "Latest pre-migration backup: ${latest}" - read -rp "Restore ${db_name} from this backup? [y/N] " reply - [[ "$reply" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } - - echo "Restoring..." - cd "$INSTALL_DIR" - docker compose exec -T db mariadb -uroot -p"${DB_ROOT_PASS}" \ - -e "DROP DATABASE IF EXISTS \`${db_name}\`; CREATE DATABASE \`${db_name}\` CHARACTER SET utf8mb4;" - gunzip -c "$latest" | docker compose exec -T db mariadb \ - -uroot -p"${DB_ROOT_PASS}" "${db_name}" - - docker compose exec -T "php_${env}" bash -c \ - "cd /var/www/html/${env} && ./vendor/bin/drush cr" - - echo " ✓ Rollback complete" - echo " Run: actools health to verify" -} -#!/usr/bin/env bash -# ============================================================================= -# modules/observability/prometheus.sh — Prometheus + Grafana Stack -# Phase 2: Optional observability — enable with ENABLE_OBSERVABILITY=true -# ============================================================================= - -start_observability() { - local install_dir="${INSTALL_DIR:-/home/actools}" - - echo "=== Starting Observability Stack ===" - echo "Prometheus + Grafana + exporters" - echo "" - - # Create data directories - mkdir -p "${install_dir}/observability/prometheus" - mkdir -p "${install_dir}/observability/grafana" - chown -R 472:472 "${install_dir}/observability/grafana" 2>/dev/null || true - - # Copy prometheus config - cp "${install_dir}/templates/grafana/prometheus.yml" \ - "${install_dir}/observability/prometheus/prometheus.yml" - - # Generate observability compose file - cat > "${install_dir}/docker-compose.observability.yml" << OBSCOMPOSE -networks: - actools_actools_net: - external: true - -services: - prometheus: - image: prom/prometheus:latest - container_name: actools_prometheus - restart: unless-stopped - ports: - - "127.0.0.1:9090:9090" - volumes: - - ./observability/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - - ./observability/prometheus:/prometheus - command: - - '--config.file=/etc/prometheus/prometheus.yml' - - '--storage.tsdb.path=/prometheus' - - '--storage.tsdb.retention.time=30d' - networks: - - actools_actools_net - - grafana: - image: grafana/grafana:latest - container_name: actools_grafana - restart: unless-stopped - ports: - - "127.0.0.1:3000:3000" - volumes: - - ./observability/grafana:/var/lib/grafana - environment: - GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_PASS:-actools_grafana}" - GF_USERS_ALLOW_SIGN_UP: "false" - GF_SERVER_ROOT_URL: "https://${BASE_DOMAIN}/grafana" - networks: - - actools_actools_net - - node_exporter: - image: prom/node-exporter:latest - container_name: actools_node_exporter - restart: unless-stopped - pid: host - volumes: - - /proc:/host/proc:ro - - /sys:/host/sys:ro - - /:/rootfs:ro - command: - - '--path.procfs=/host/proc' - - '--path.sysfs=/host/sys' - - '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)' - networks: - - actools_actools_net - - redis_exporter: - image: oliver006/redis_exporter:latest - container_name: actools_redis_exporter - restart: unless-stopped - environment: - REDIS_ADDR: "redis://actools_redis:6379" - networks: - - actools_actools_net - - cadvisor: - image: gcr.io/cadvisor/cadvisor:latest - container_name: actools_cadvisor - restart: unless-stopped - privileged: true - volumes: - - /:/rootfs:ro - - /var/run:/var/run:ro - - /sys:/sys:ro - - /var/lib/docker:/var/lib/docker:ro - networks: - - actools_actools_net -OBSCOMPOSE - - echo "Starting observability containers..." - docker compose -f "${install_dir}/docker-compose.observability.yml" up -d - - echo "" - echo "=== Observability Stack Started ===" - echo "Prometheus : http://localhost:9090" - echo "Grafana : http://localhost:3000" - echo "Login : admin / ${GRAFANA_PASS:-actools_grafana}" - echo "" - echo "To access from your browser, run on your local machine:" - echo " ssh -L 3000:localhost:3000 actools@${BASE_DOMAIN}" - echo "Then open: http://localhost:3000" -} - -stop_observability() { - local install_dir="${INSTALL_DIR:-/home/actools}" - docker compose -f "${install_dir}/docker-compose.observability.yml" down - echo "Observability stack stopped." -} - -status_observability() { - local install_dir="${INSTALL_DIR:-/home/actools}" - docker compose -f "${install_dir}/docker-compose.observability.yml" ps 2>/dev/null \ - || echo "Observability stack not running." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/preflight/disk.sh — Disk Space Checks -# ============================================================================= - -check_disk() { - section "Disk Check" - AVAILABLE_KB=$(df / | awk 'NR==2 {print $4}') - (( AVAILABLE_KB < 20971520 )) && \ - error "Only $(( AVAILABLE_KB / 1048576 ))GB free. At least 20GB required." - log "Disk OK -- $(( AVAILABLE_KB / 1048576 ))GB free." - - DISK_USE=$(df / | awk 'NR==2 {print $5}' | tr -d '%') - (( DISK_USE > 80 )) && warn "Disk ${DISK_USE}% full -- risk of failure during install." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/preflight/dns.sh — DNS Resolution Checks -# ============================================================================= - -check_dns() { - section "DNS Check" - for subdomain in "${BASE_DOMAIN}" "stg.${BASE_DOMAIN}" "dev.${BASE_DOMAIN}"; do - getent hosts "$subdomain" >/dev/null 2>&1 \ - && log "DNS OK -- ${subdomain}" \ - || warn "DNS MISSING -- ${subdomain}. Let's Encrypt will fail until DNS propagates." - done -} -#!/usr/bin/env bash -# ============================================================================= -# modules/preflight/ram.sh — RAM Check + Parallel Install Guard -# ============================================================================= - -check_ram() { - section "RAM Check" - TOTAL_RAM=$(free -m | awk '/Mem:/ {print $2}') - log "Total RAM: ${TOTAL_RAM}MB" - - if [[ "${PARALLEL_INSTALL:-false}" == "true" ]] && (( TOTAL_RAM < 6000 )); then - warn "Only ${TOTAL_RAM}MB RAM -- forcing sequential install (need 6GB+ for parallel)." - PARALLEL_INSTALL=false - fi - - (( TOTAL_RAM < 1024 )) && warn "Less than 1GB RAM -- install may fail on low-memory server." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/preview/branch.sh — Phase 3: Ephemeral Preview Environments -# Usage: actools branch feature-123 -# actools branch --list -# actools branch --destroy feature-123 -# ============================================================================= - -INSTALL_DIR="${INSTALL_DIR:-/home/actools}" -PREVIEW_DIR="${INSTALL_DIR}/previews" -PREVIEW_STATE="${INSTALL_DIR}/.preview-state.json" - -# Initialise preview state file -init_preview_state() { - [[ -f "$PREVIEW_STATE" ]] || echo '{"previews":{}}' > "$PREVIEW_STATE" -} - -# Sanitise branch name — only alphanumeric and hyphens -sanitise_branch() { - echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-//;s/-$//' -} - -branch_create() { - local raw_name="$1" - local branch - branch=$(sanitise_branch "$raw_name") - - if [[ -z "$branch" ]]; then - echo "ERROR: branch name required. Usage: actools branch feature-123" - exit 1 - fi - - local domain="${branch}.${BASE_DOMAIN}" - local db_name="actools_pr_${branch//-/_}" - local db_pass - db_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | head -c 16) - local admin_pass - admin_pass=$(openssl rand -base64 12 | tr -dc 'A-Za-z0-9' | head -c 16) - local php_svc="php_pr_${branch//-/_}" - local container="actools_php_pr_${branch//-/_}" - - echo "" - echo "=== Creating Preview Environment ===" - echo "Branch : ${branch}" - echo "Domain : https://${domain}" - echo "DB : ${db_name}" - echo "" - - init_preview_state - - # Check if already exists - if jq -e ".previews.\"${branch}\"" "$PREVIEW_STATE" &>/dev/null; then - echo "Preview '${branch}' already exists. Destroy it first:" - echo " actools branch --destroy ${branch}" - exit 1 - fi - - # Step 1: Clone prod database - echo "Step 1/5: Cloning production database..." - cd "$INSTALL_DIR" - docker compose exec -T db mariadb -uroot -p"${DB_ROOT_PASS}" <> "$settings_file" << SETTINGS - -// Preview environment override — ${branch} -\$databases['default']['default']['database'] = '${db_name}'; -\$databases['default']['default']['username'] = '${db_name}'; -\$databases['default']['default']['password'] = '${db_pass}'; -\$databases['default']['default']['host'] = 'db'; -\$settings['trusted_host_patterns'][] = '^${branch//./\\.}\\.${BASE_DOMAIN//./\\.}$'; -SETTINGS - echo " ✓ Settings updated" - fi - - # Step 4: Start PHP container for preview - echo "Step 4/5: Starting preview container..." - docker run -d \ - --name "${container}" \ - --network actools_actools_net \ - --restart unless-stopped \ - -v "${INSTALL_DIR}/docroot/previews/${branch}:/var/www/html/prod" \ - -v "${INSTALL_DIR}/logs/php_pr_${branch//-/_}:/var/log/php" \ - -e PHP_MEMORY_LIMIT=512m \ - -e DB_ROOT_PASS="${DB_ROOT_PASS}" \ - --label "actools.preview=true" \ - --label "actools.branch=${branch}" \ - --label "actools.created=$(date +%F)" \ - drupal:11-php8.3-fpm - echo " ✓ Container started: ${container}" - - # Step 5: Add Caddy vhost - echo "Step 5/5: Adding Caddy vhost..." - cat >> "${INSTALL_DIR}/Caddyfile" << CADDY - -${domain} { - root * /var/www/html/prod/web - php_fastcgi ${container}:9000 - import drupal_base - tls ${DRUPAL_ADMIN_EMAIL} -} -CADDY - - # Update Caddy docroot mount and reload - docker exec actools_caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null - echo " ✓ Caddy vhost added" - - # Save state - local created_at - created_at=$(date +%F) - jq ".previews.\"${branch}\" = { - \"domain\": \"${domain}\", - \"db_name\": \"${db_name}\", - \"db_pass\": \"${db_pass}\", - \"container\": \"${container}\", - \"created\": \"${created_at}\", - \"admin_pass\": \"${admin_pass}\" - }" "$PREVIEW_STATE" > /tmp/ps.tmp && mv /tmp/ps.tmp "$PREVIEW_STATE" - - echo "" - echo "=== Preview Environment Ready ===" - echo " URL : https://${domain}" - echo " Admin : https://${domain}/user/login" - echo " User : admin" - echo " Password : ${admin_pass}" - echo " DB : ${db_name}" - echo " Expires : $(date -d '+7 days' +%F)" - echo "" - echo "Destroy when done: actools branch --destroy ${branch}" -} - -branch_destroy() { - local branch - branch=$(sanitise_branch "$1") - - echo "=== Destroying Preview Environment: ${branch} ===" - init_preview_state - - local container="actools_php_pr_${branch//-/_}" - local db_name="actools_pr_${branch//-/_}" - local domain="${branch}.${BASE_DOMAIN}" - - # Stop and remove container - docker stop "${container}" 2>/dev/null && \ - docker rm "${container}" 2>/dev/null && \ - echo " ✓ Container removed" || \ - echo " ! Container already gone" - - # Drop database - cd "$INSTALL_DIR" - docker compose exec -T db mariadb -uroot -p"${DB_ROOT_PASS}" </dev/null || true - sudo rm -rf "${INSTALL_DIR}/docroot/previews/${branch}" - echo " ✓ Docroot removed" - - # Remove Caddy vhost - python3 << PYEOF -import re -with open('${INSTALL_DIR}/Caddyfile') as f: - content = f.read() -pattern = r'\n${domain} \{[^}]+\}\n' -content = re.sub(pattern, '\n', content, flags=re.DOTALL) -with open('${INSTALL_DIR}/Caddyfile', 'w') as f: - f.write(content) -print(" ✓ Caddy vhost removed") -PYEOF - - docker exec actools_caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null - - # Remove from state - jq "del(.previews.\"${branch}\")" "$PREVIEW_STATE" > /tmp/ps.tmp && \ - mv /tmp/ps.tmp "$PREVIEW_STATE" - - echo "" - echo " ✓ Preview environment '${branch}' destroyed" -} - -branch_list() { - init_preview_state - echo "" - echo "=== Active Preview Environments ===" - local count - count=$(jq '.previews | length' "$PREVIEW_STATE") - if [[ "$count" -eq 0 ]]; then - echo " No active preview environments." - else - jq -r '.previews | to_entries[] | " \(.key)\n URL: https://\(.value.domain)\n Created: \(.value.created)\n DB: \(.value.db_name)"' \ - "$PREVIEW_STATE" - fi - echo "" -} - -branch_cleanup() { - init_preview_state - echo "=== Auto-cleanup: removing previews older than 7 days ===" - local cutoff - cutoff=$(date -d '-7 days' +%F) - jq -r ".previews | to_entries[] | select(.value.created <= \"${cutoff}\") | .key" \ - "$PREVIEW_STATE" | while read -r branch; do - echo "Destroying expired preview: ${branch} (created: $(jq -r ".previews.\"${branch}\".created" "$PREVIEW_STATE"))" - branch_destroy "$branch" - done -} -#!/usr/bin/env bash -# ============================================================================= -# modules/stack/caddyfile.sh — Caddyfile Generation -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -generate_caddyfile() { - cat > "$INSTALL_DIR/Caddyfile" < "$INSTALL_DIR/docker-compose.yml" </dev/null || true; sleep 60; done"] - volumes: - - ./docroot/prod:/var/www/html/prod - - ./logs/worker:/var/log/worker - environment: - PHP_MEMORY_LIMIT: "${WORKER_MEM}" - XELATEX_MODE: "${XELATEX_MODE:-local}" - XELATEX_ENDPOINT: "${XELATEX_ENDPOINT:-}" - AWS_ACCESS_KEY_ID: "${AWS_ACCESS_KEY_ID:-}" - AWS_SECRET_ACCESS_KEY: "${AWS_SECRET_ACCESS_KEY:-}" - S3_BUCKET: "${S3_BUCKET:-}" - STORAGE_PROVIDER: "${STORAGE_PROVIDER:-}" - AWS_REGION: "${AWS_REGION:-us-east-1}" - S3_ENDPOINT_URL: "${S3_ENDPOINT_URL:-}" - ASSET_CDN_HOST: "${ASSET_CDN_HOST:-}" - mem_limit: "${WORKER_MEM}" - tmpfs: - - /tmp:size=256m,mode=1777 - security_opt: - - no-new-privileges:true - cap_drop: - - ALL - cap_add: - - SETUID - - SETGID - - DAC_OVERRIDE - healthcheck: - test: ["CMD-SHELL", "php -v && xelatex --version > /dev/null 2>&1"] - interval: 60s - timeout: 15s - retries: 3 - start_period: 60s - depends_on: - db: - condition: service_healthy - redis: - condition: service_started - networks: - - actools_net - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - - db: - image: mariadb:${MARIADB_VERSION} - container_name: actools_db - restart: unless-stopped - stop_grace_period: 2m - environment: - MARIADB_ROOT_PASSWORD: "${DB_ROOT_PASS}" - MARIADB_AUTO_UPGRADE: "1" - volumes: - - db_data:/var/lib/mysql - - ./logs/db:/var/log/mysql - - ./my.cnf:/etc/mysql/conf.d/actools.cnf:ro - healthcheck: - test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] - interval: 10s - timeout: 5s - retries: 8 - start_period: 30s - networks: - - actools_net - mem_limit: "${DB_MEM}" - logging: - driver: "json-file" - options: - max-size: "10m" - max-file: "3" - -$(if [[ "${REDIS_ON}" == "true" ]]; then -cat < "$INSTALL_DIR/Dockerfile.caddy" <<'CADDY_DOCKERFILE' -FROM caddy:2.8-builder AS builder -RUN xcaddy build \ - --with github.com/mholt/caddy-ratelimit - -FROM caddy:2.8-alpine -COPY --from=builder /usr/bin/caddy /usr/bin/caddy -CADDY_DOCKERFILE - - log "Building custom Caddy image..." - docker build -t actools_caddy:custom \ - -f "$INSTALL_DIR/Dockerfile.caddy" "$INSTALL_DIR" \ - || error "Caddy image build failed." - log "Custom Caddy image built." -} - -build_worker_image() { - cat > "$INSTALL_DIR/Dockerfile.worker" < "$INSTALL_DIR/my.cnf" </dev/null || true - " 2>/dev/null || warn "S3FS installation failed -- configure manually after install" - log "S3FS installed for ${env}." - fi -} -#!/usr/bin/env bash -# ============================================================================= -# modules/storage/settings_inject.sh — S3 settings.php Injection -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -inject_s3_settings() { - local env="$1" - - log "Injecting S3 credentials into settings.php for ${env}..." - docker compose exec -T "php_${env}" bash -c " - CONFIG_FILE=/var/www/html/${env}/sites/default/settings.php - cat >> \"\$CONFIG_FILE\" <<'SETTINGS' - -// S3FS configuration -- injected by actools installer (v9.2) -// Credentials read from container env vars. Never in config export. -\$config['s3fs.settings']['access_key'] = getenv('AWS_ACCESS_KEY_ID') ?: ''; -\$config['s3fs.settings']['secret_key'] = getenv('AWS_SECRET_ACCESS_KEY') ?: ''; -\$config['s3fs.settings']['bucket'] = getenv('S3_BUCKET') ?: ''; -\$config['s3fs.settings']['region'] = getenv('AWS_REGION') ?: 'us-east-1'; -\$config['s3fs.settings']['use_s3_for_public'] = TRUE; -\$config['s3fs.settings']['use_s3_for_private'] = TRUE; - -\$_s3_endpoint = getenv('S3_ENDPOINT_URL'); -if (!empty(\$_s3_endpoint)) { - \$config['s3fs.settings']['use_customhost'] = TRUE; - \$config['s3fs.settings']['hostname'] = \$_s3_endpoint; -} - -\$_cdn_host = getenv('ASSET_CDN_HOST'); -if (!empty(\$_cdn_host)) { - \$config['s3fs.settings']['use_cname'] = TRUE; - \$config['s3fs.settings']['domain'] = \$_cdn_host; -} -SETTINGS - " 2>/dev/null || warn "S3 settings.php injection failed for ${env}" - log "S3 settings injected for ${env} (provider: ${STORAGE_PROVIDER})." -} -#!/usr/bin/env bash -# ============================================================================= -# modules/worker/queue.sh — Queue Worker Configuration -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -worker_status() { - cd "$INSTALL_DIR" - docker compose exec worker_prod bash -c \ - "cd /var/www/html/prod && ./vendor/bin/drush queue:list" \ - 2>/dev/null || warn "Worker container not ready or drush not installed yet." -} - -worker_run() { - cd "$INSTALL_DIR" - log "Running queue worker manually on prod..." - docker compose exec worker_prod bash -c \ - "cd /var/www/html/prod && ./vendor/bin/drush queue:run actools_document_export" -} -#!/usr/bin/env bash -# ============================================================================= -# modules/worker/xelatex.sh — XeLaTeX Worker Image -# Extracted from actools.sh v9.2 during Phase 1 modular refactor -# ============================================================================= - -build_worker_image() { - cat > "$INSTALL_DIR/Dockerfile.worker" < "$output" - - echo " ✓ Generated: .github/workflows/${filename}" - done - - echo "" - echo "=== Generated Files ===" - echo "Location: ${output_dir}" - echo "" - echo " .github/workflows/github-test.yml" - echo " → Runs on every PR: PHP CodeSniffer, PHPStan, composer validate" - echo "" - echo " .github/workflows/github-deploy.yml" - echo " → Runs on merge to main: backup + pull + updb + health check" - echo "" - echo " .github/workflows/github-security.yml" - echo " → Runs weekly: composer audit + Drupal security advisories" - echo "" - echo "=== Next Steps ===" - echo "1. Copy .github/ to your Drupal repo root:" - echo " cp -r ${output_dir}/.github /path/to/your/drupal/repo/" - echo "" - echo "2. Add SSH deploy key to GitHub secrets:" - echo " GitHub repo → Settings → Secrets → DEPLOY_SSH_KEY" - echo " (use the private key that has SSH access to ${BASE_DOMAIN})" - echo "" - echo "3. Push to GitHub — workflows activate automatically" - echo "" -} -#!/usr/bin/env bash -# ============================================================================= -# cli/commands/cost_optimize.sh — Phase 2: Cost & Memory Optimization -# Reads real Docker stats and suggests memory limit changes -# ============================================================================= - -cmd_cost_optimize() { - local stats_dir="/home/actools/logs/stats" - local today - today=$(date +%F) - - echo "" - echo "=== Actools Cost & Memory Optimizer ===" - echo "Analysing Docker stats from: ${stats_dir}" - echo "" - - if ! ls "${stats_dir}"/*.jsonl &>/dev/null; then - echo "No stats data found. Stats collect hourly via cron." - echo "Run manually: sudo /etc/cron.hourly/actools-stats" - return 1 - fi - - # Count data points - local total_lines - total_lines=$(cat "${stats_dir}"/*.jsonl 2>/dev/null | wc -l) - local days - days=$(ls "${stats_dir}"/*.jsonl 2>/dev/null | wc -l) - echo "Data points : ${total_lines} readings across ${days} day(s)" - echo "Latest file : ${today}.jsonl" - echo "" - - # Analyse each container - local containers=("actools_caddy" "actools_php_prod" "actools_worker_prod" "actools_redis" "actools_db") - - printf "%-22s %-12s %-12s %-12s %-20s\n" "Container" "Peak MiB" "Avg MiB" "Limit" "Recommendation" - printf "%-22s %-12s %-12s %-12s %-20s\n" "---------" "--------" "-------" "-----" "--------------" - - for container in "${containers[@]}"; do - # Extract memory usage in MiB for this container - local readings - readings=$(cat "${stats_dir}"/*.jsonl 2>/dev/null \ - | grep "\"container\":\"${container}\"" \ - | grep -v '"mem":"0B' \ - | sed 's/.*"mem":"\([0-9.]*\)MiB.*/\1/' \ - | grep -E '^[0-9]') - - if [[ -z "$readings" ]]; then - printf "%-22s %-12s %-12s %-12s %-20s\n" "$container" "no data" "-" "-" "insufficient data" - continue - fi - - # Calculate peak and average - local peak avg - peak=$(echo "$readings" | awk 'BEGIN{max=0} {if($1>max)max=$1} END{printf "%.0f", max}') - avg=$(echo "$readings" | awk '{sum+=$1; count++} END{printf "%.0f", sum/count}') - - # Get current limit from running container - local limit_raw limit_display - limit_raw=$(docker inspect "${container}" \ - --format='{{.HostConfig.Memory}}' 2>/dev/null || echo "0") - - if [[ "$limit_raw" == "0" ]]; then - limit_display="unlimited" - else - limit_display="$(( limit_raw / 1048576 ))MiB" - fi - - # Generate recommendation - local recommendation - local limit_mib=$(( limit_raw / 1048576 )) - - if [[ "$limit_raw" == "0" ]]; then - recommendation="set a limit" - elif [[ $peak -lt $(( limit_mib / 4 )) ]]; then - local suggested=$(( peak * 2 )) - recommendation="reduce to ${suggested}MiB (saves $(( limit_mib - suggested ))MiB)" - elif [[ $peak -gt $(( limit_mib * 85 / 100 )) ]]; then - local suggested=$(( peak * 2 )) - recommendation="INCREASE to ${suggested}MiB (at risk!)" - else - recommendation="OK — within safe range" - fi - - printf "%-22s %-12s %-12s %-12s %-20s\n" \ - "$container" "${peak}MiB" "${avg}MiB" "$limit_display" "$recommendation" - done - - echo "" - echo "=== MariaDB Buffer Pool Analysis ===" - local bp_hit_rate - bp_hit_rate=$(docker exec actools_db mariadb -uroot -p"${DB_ROOT_PASS}" -sN \ - -e "SELECT ROUND((1 - (SELECT VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS - WHERE VARIABLE_NAME='Innodb_buffer_pool_reads') / - (SELECT VARIABLE_VALUE FROM information_schema.GLOBAL_STATUS - WHERE VARIABLE_NAME='Innodb_buffer_pool_read_requests')) * 100, 2) - AS hit_rate;" 2>/dev/null || echo "unavailable") - - echo "InnoDB buffer pool hit rate: ${bp_hit_rate}%" - if [[ "$bp_hit_rate" != "unavailable" ]]; then - local rate_int - rate_int=$(echo "$bp_hit_rate" | cut -d. -f1) - if (( rate_int >= 95 )); then - echo "Status: GOOD — buffer pool is well sized" - elif (( rate_int >= 85 )); then - echo "Status: OK — consider increasing INNODB_BUFFER_POOL slightly" - else - echo "Status: LOW — increase INNODB_BUFFER_POOL in actools.env" - fi - fi - - echo "" - echo "=== Redis Memory Analysis ===" - local redis_used redis_max - redis_used=$(docker exec actools_redis redis-cli info memory 2>/dev/null \ - | grep "used_memory_human" | cut -d: -f2 | tr -d '[:space:]') - redis_max=$(docker exec actools_redis redis-cli info memory 2>/dev/null \ - | grep "maxmemory_human" | cut -d: -f2 | tr -d '[:space:]') - echo "Redis used: ${redis_used:-unavailable} / Max: ${redis_max:-unavailable}" - - echo "" - echo "=== Suggested actools.env changes ===" - echo "Review the recommendations above and update actools.env accordingly." - echo "Then run: sudo ./actools.sh update" - echo "" - echo "Note: Changes only apply after 'docker compose up -d' restarts containers." -} -#!/usr/bin/env bash -# ============================================================================= -# cli/commands/health.sh — Health Check CLI Command -# ============================================================================= - -cmd_health() { - local mode="${1:-simple}" - local INSTALL_DIR="/home/actools" - - # Load env for BASE_DOMAIN and DB_ROOT_PASS - source "${INSTALL_DIR}/actools.env" 2>/dev/null || true - - case "$mode" in - --verbose|-v) - source "${INSTALL_DIR}/modules/health/checks.sh" - health_check_all - ;; - --cost|-c) - source "${INSTALL_DIR}/cli/commands/cost_optimize.sh" - cmd_cost_optimize - ;; - *) - # Simple mode — just HTTP check - for env in prod; do - local dom="${BASE_DOMAIN}" - local http hlth - http=$(curl -sso /dev/null -w "%{http_code}" --max-time 5 "https://$dom" 2>/dev/null || echo "ERR") - hlth=$(curl -sso /dev/null -w "%{http_code}" --max-time 5 "https://$dom/health" 2>/dev/null || echo "ERR") - echo "$dom: HTTP=$http /health=$hlth" - done - echo "" - echo "Run 'actools health --verbose' for full system health report." - echo "Run 'actools health --cost' for memory optimization report." - ;; - esac -} -#!/usr/bin/env bash -# ============================================================================= -# cli/commands/restore.sh — Restore Commands -# ============================================================================= - -cmd_restore_test() { - cd "$INSTALL_DIR" - LATEST=$(ls -t "${INSTALL_DIR}/backups"/prod_db_*.sql.gz 2>/dev/null | head -1) - [[ -z "$LATEST" ]] && { echo "No prod DB backups found"; exit 1; } - echo "Testing DB restore: $LATEST" - sha256sum -c "$LATEST.sha256" && echo "Checksum OK" || { echo "CHECKSUM FAILED"; exit 1; } - docker exec actools_db mariadb -uroot -p"${DB_ROOT_PASS}" \ - -e "CREATE DATABASE IF NOT EXISTS actools_restore_test CHARACTER SET utf8mb4;" - gunzip -c "$LATEST" | docker exec -i actools_db mariadb \ - -uroot -p"${DB_ROOT_PASS}" actools_restore_test - TC=$(docker exec actools_db mariadb -uroot -p"${DB_ROOT_PASS}" -sN \ - -e "SELECT count(*) FROM information_schema.tables WHERE table_schema='actools_restore_test';") - docker exec actools_db mariadb -uroot -p"${DB_ROOT_PASS}" \ - -e "DROP DATABASE IF EXISTS actools_restore_test;" - echo "DB restore test OK -- ${TC} tables restored." -} - -cmd_restore() { - cd "$INSTALL_DIR" - local env="${1:-prod}" - local db="actools_${env}" - local BACKUP_FILE="${2:-}" - [[ -z "$BACKUP_FILE" ]] && \ - BACKUP_FILE=$(ls -t "${INSTALL_DIR}/backups/${env}_db_"*.sql.gz 2>/dev/null | head -1) - [[ -z "$BACKUP_FILE" ]] && { echo "No backups found for $env"; exit 1; } - echo "Restoring $env from: $BACKUP_FILE" - sha256sum -c "$BACKUP_FILE.sha256" 2>/dev/null && echo "Checksum OK" \ - || echo "WARNING: no checksum file" - read -rp "OVERWRITE actools_${env}? [y/N] " reply - [[ "$reply" =~ ^[Yy]$ ]] || { echo "Aborted."; exit 0; } - docker exec actools_db mariadb -uroot -p"${DB_ROOT_PASS}" \ - -e "DROP DATABASE IF EXISTS \`$db\`; CREATE DATABASE \`$db\` CHARACTER SET utf8mb4;" - gunzip -c "$BACKUP_FILE" | docker exec -i actools_db mariadb \ - -uroot -p"${DB_ROOT_PASS}" "$db" - echo "Restore complete. Run: actools drush $env cr" -} -#!/usr/bin/env bash -# ============================================================================= -# cli/commands/storage.sh — Storage Commands -# ============================================================================= - -cmd_storage_test() { - cd "$INSTALL_DIR" - echo "=== S3 Storage Round-Trip Test ===" - docker compose exec php_prod bash -c " - cd /var/www/html/prod - ./vendor/bin/drush php:eval \" - \\\$test_content = 'actools-storage-test-' . time(); - \\\$uri = 's3://actools-roundtrip-test.txt'; - \\\$written = file_put_contents(\\\$uri, \\\$test_content); - if (\\\$written === false) { echo 'WRITE FAILED'; exit(1); } - echo 'WRITE OK (' . \\\$written . ' bytes)'; - \\\$read = file_get_contents(\\\$uri); - echo \\\$read === \\\$test_content ? 'READ OK' : 'READ FAILED'; - \\\$deleted = unlink(\\\$uri); - echo \\\$deleted ? 'DELETE OK' : 'DELETE FAILED'; - echo 'Round-trip: ' . (\\\$read === \\\$test_content && \\\$deleted ? 'PASS' : 'FAIL'); - \" 2>/dev/null || echo 'S3 stream test failed' - " 2>/dev/null || echo "Could not connect to php_prod" -} - -cmd_storage_info() { - ENV_FILE="${INSTALL_DIR}/actools.env" - [[ -f "$ENV_FILE" ]] && { set -a; source "$ENV_FILE"; set +a; } - echo "=== S3 Storage Configuration ===" - echo "Provider : ${STORAGE_PROVIDER:-not set}" - echo "Bucket : ${S3_BUCKET:-not set}" - echo "Endpoint : ${S3_ENDPOINT_URL:-not set}" - echo "CDN host : ${ASSET_CDN_HOST:-not set}" - echo "XeLaTeX : ${XELATEX_MODE:-local}" -} -#!/usr/bin/env bash -# ============================================================================= -# cli/commands/update.sh — Update Command -# ============================================================================= - -cmd_update() { - cd "$INSTALL_DIR" - echo "Taking pre-update prod snapshot..." - SNAP="${INSTALL_DIR}/backups/pre_update_prod_$(date +%F_%H%M%S).sql.gz" - docker exec actools_db mariadb-dump --single-transaction --quick \ - -ubackup actools_prod \ - | gzip > "$SNAP" && echo "Snapshot: $SNAP" || echo "Snapshot failed (non-fatal)" - docker compose pull db redis php_prod - docker compose up -d - docker compose exec -T php_prod bash -c \ - "cd /var/www/html/prod && ./vendor/bin/drush updb --yes && ./vendor/bin/drush cr" \ - 2>&1 || echo "drush updb failed -- check manually" - docker exec actools_caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null || true - echo "Update complete." -} -#!/usr/bin/env bash -# ============================================================================= -# cli/commands/worker.sh — Worker Commands -# ============================================================================= - -cmd_worker_logs() { - cd "$INSTALL_DIR" - docker compose logs -f worker_prod -} - -cmd_worker_status() { - cd "$INSTALL_DIR" - docker compose exec worker_prod bash -c \ - "cd /var/www/html/prod && ./vendor/bin/drush queue:list" -} - -cmd_worker_run() { - cd "$INSTALL_DIR" - log "Running queue worker manually on prod..." - docker compose exec worker_prod bash -c \ - "cd /var/www/html/prod && ./vendor/bin/drush queue:run actools_document_export" -} - -cmd_pdf_test() { - cd "$INSTALL_DIR" - echo "=== XeLaTeX Test (inside worker container) ===" - docker compose exec worker_prod xelatex --version 2>/dev/null \ - && echo "XeLaTeX: OK" \ - || echo "XeLaTeX: FAILED -- rebuild: docker build -t actools_worker:latest -f ~/Dockerfile.worker ~/" - docker inspect actools_worker_prod \ - --format=' Health: {{.State.Health.Status}}' 2>/dev/null \ - || echo " (container not running)" -} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6570916..28b24f6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -46,11 +46,11 @@ jobs: shellcheck --exclude=SC2034,SC2015,SC2164,SC2043,SC2012,SC1090,SC1091 cli/commands/*.sh shellcheck --exclude=SC2034,SC2015,SC2164,SC2012 installer/*.sh shellcheck --exclude=SC2034,SC2015,SC2164,SC1091 modules/audit/*.sh - shellcheck --exclude=SC2034,SC2015,SC2164,SC1090 modules/dr/*.sh - shellcheck --exclude=SC2034,SC2015,SC2164 modules/observability/*.sh + shellcheck --exclude=SC2034,SC2015,SC2164,SC1090 experimental/dr/*.sh + shellcheck --exclude=SC2034,SC2015,SC2164 experimental/observability/*.sh shellcheck --exclude=SC2034,SC2164 modules/drupal/*.sh shellcheck --severity=warning --exclude=SC2034,SC2015,SC2164,SC1090,SC1091 modules/audit/lib/*.sh - shellcheck --exclude=SC2034,SC2015,SC2164,SC2119,SC2120 modules/preview/*.sh + shellcheck --exclude=SC2034,SC2015,SC2164,SC2119,SC2120 experimental/preview/*.sh shellcheck --exclude=SC2034,SC2015,SC2164 modules/stack/*.sh trivy: runs-on: ubuntu-latest diff --git a/ROADMAP.md b/ROADMAP.md index d75f7d1..2571518 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -67,7 +67,7 @@ Current state: `docs/hardening.md` describes MariaDB TLS as not enabled by defau **What exists:** - `CADDY_CLOUDFLARE_TOKEN` placeholder in `actools.env.example` -- Documentation in `modules/network/cloudflare-setup.md` describing DNS-01 and Origin-Cert options +- Documentation in `experimental/network/cloudflare-setup.md` describing DNS-01 and Origin-Cert options **What's missing:** - The Caddy image does not include the `caddy-dns/cloudflare` provider plugin @@ -111,7 +111,7 @@ Current state: `docs/operations.md` describes the update flow and names manual r **Status:** Deployed in disaster-recovery installations only, not in standard install **What exists:** -- Audit wrapper at `modules/security/actools-audit` +- Audit wrapper at `experimental/security/actools-audit` - The disaster-recovery resurrection path installs the wrapper chain (`actools` symlink → `actools-audit` → `actools-real`) **What's missing:** diff --git a/actools.env.example b/actools.env.example index 5768f75..501b283 100644 --- a/actools.env.example +++ b/actools.env.example @@ -17,7 +17,7 @@ DRUPAL_ADMIN_EMAIL=admin@example.com # Required when running behind a Cloudflare Tunnel with ports 80/443 closed. # Token needs Zone:DNS:Edit permission for your domain. # Leave blank for standard HTTP-01 challenge (open ports). -# See modules/network/cloudflare-setup.md for tunnel TLS posture options. +# See experimental/network/cloudflare-setup.md for tunnel TLS posture options. CADDY_CLOUDFLARE_TOKEN= # -- Site Identity ------------------------------------------------------------ diff --git a/docs/advanced.md b/docs/advanced.md index da26d65..c3df7c2 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -49,7 +49,7 @@ How it works (planned): `db-full-backup.sh` runs daily at 02:00, dumping with `- ## DNA resurrection -> **Experimental — not wired.** `actools immortalize` and `actools resurrect` are **not** registered commands. The scripts live in `modules/dr/`, are unvalidated against the current stack, and `resurrect.sh` would install a separate `actools-real` binary — do **not** run it on a live server. This section is design reference only. +> **Experimental — not wired.** `actools immortalize` and `actools resurrect` are **not** registered commands. The scripts live in `experimental/dr/`, are unvalidated against the current stack, and `resurrect.sh` would install a separate `actools-real` binary — do **not** run it on a live server. This section is design reference only. The design: `immortalize` captures a complete server blueprint — OS, Docker versions, container manifests, modules, binlog position, redacted env keys — into an age-encrypted JSON snapshot. `resurrect` would replay it on a fresh server in 11 steps (install dependencies → create user → clone repo → restore secrets → start stack → restore database → install CLI + cron + RBAC → health check). @@ -67,7 +67,7 @@ A password manager or encrypted vault is fine. **Do not commit these to git.** ## GDPR compliance -> **Experimental — not wired.** `actools gdpr …` is **not** a registered command. The code lives in `modules/compliance/gdpr.sh` and is not validated against the current Drupal version. Design reference only. +> **Experimental — not wired.** `actools gdpr …` is **not** a registered command. The code lives in `experimental/compliance/gdpr.sh` and is not validated against the current Drupal version. Design reference only. Planned surface: @@ -85,7 +85,7 @@ Planned export format: JSON in `backups/gdpr-exports/` with profile, roles, cont ## Preview environments -> **Experimental — not wired.** `actools branch …` is **not** a registered command. The code lives in `modules/preview/branch.sh`. Design reference only. +> **Experimental — not wired.** `actools branch …` is **not** a registered command. The code lives in `experimental/preview/branch.sh`. Design reference only. The design: per-branch isolated Drupal environments for PR previews, design reviews, and risky migrations. @@ -119,7 +119,7 @@ Would generate three workflows from templates — **test** (PR: CodeSniffer, PHP ## AI assistant -> **Experimental — not wired.** `actools ai …` is **not** a registered command. The code lives in `modules/ai/assistant.sh`. Design reference only. +> **Experimental — not wired.** `actools ai …` is **not** a registered command. The code lives in `experimental/ai/assistant.sh`. Design reference only. The design: a small local model with codebase context for "how does this script work" questions. @@ -145,9 +145,9 @@ actools tunnel restart actools tunnel logs ``` -The tunnel runs as a systemd service (`cloudflared.service`). Configuration template in `modules/network/cloudflared-config.yml.example`. Once active, you can remove the UFW rules for 80/443 — only SSH remains inbound. +The tunnel runs as a systemd service (`cloudflared.service`). Configuration template in `experimental/network/cloudflared-config.yml.example`. Once active, you can remove the UFW rules for 80/443 — only SSH remains inbound. -See `modules/network/cloudflare-setup.md` for the one-time setup. +See `experimental/network/cloudflare-setup.md` for the one-time setup. --- diff --git a/docs/architecture/runtime-authority-map.md b/docs/architecture/runtime-authority-map.md index 393d34d..5023f4f 100644 --- a/docs/architecture/runtime-authority-map.md +++ b/docs/architecture/runtime-authority-map.md @@ -93,58 +93,61 @@ A row may carry a compound status (e.g. `current (monolithic) → target via dis - **Doc contradictions (P0-B/P0-J).** `docs/architecture.md`: "v11.2.0+" (`:3`), "the CLI … never contains business logic" (`:9`), "21 bats tests" (`:49`), `"version":"11.2.0"` + `phases_complete` state machine (`:84-87`), `actools-real` (`:119`) — all false vs code. `docs/CHANGELOG.md:113` Dockerfile claim false. `cli/actools:12-15` false self-comment. - **Design-canon home (build-trigger #2)** was **absent** before P0-A (`design/` did not exist; LOCKED/brief/arch not committed under `docs/` or `design/`). P0-A creates `design/` with the three canon files. -## Standalone modules (C1 inventory — live vs orphan) - -> **Recorded at phase C1** (doc + guard only; **no code deletion, no -> behaviour change**). This section is the human-readable mirror of -> `tests/guards/orphan_inventory_guard_test.bats`, which pins the live-module -> set. **Any phase that changes the live-module set must update BOTH** this -> table and that guard's `EXPECTED_LIVE_MODULES` list. Baseline `82ba206`. - -`modules/` holds **13** directories. **6 are LIVE** — reached by the live -install path (sourced from `actools.sh`, or referenced by the installer / -operator CLI). **7 are orphan** — no live reference anywhere in `actools.sh`, -`installer/`, or `cli/` (verified by per-module grep, recorded in -`PHASE0_LEDGER` Entry 021). The 12 original orphans split into **dead-twins** (duplicated -live inline/module logic) and **4.5-seeds** (committed 4.5 design, -quarantined into `experimental/` in C3, not deleted). **C2 removed the 5 -dead-twins** and reclassified `ai` + `preview` (previously dead-twin) **as -4.5-seeds** — their dirs stay in place for C3 — so the **7 that remain are -all 4.5-seeds**. C1 had acted on none of them — it only classified and -guarded, which is what made C2 safe. - -**Totals: 6 live · 7 orphan (all 4.5-seed, C3-quarantine-bound) · 13 total.** -5 dead-twins (`health, migrate, preflight, storage, worker`) removed in C2; -`ai` + `preview` reclassified as 4.5-seeds (C3 quarantine). (At C1 the figure -was **12 of 18** orphan — correcting the plan-of-record §2's "12 of 19" -off-by-one; C2 then removed 5, leaving **7 of 13**.) - -| module | status | evidence | disposition | +## Standalone modules (live vs quarantined) + +> **Recorded at C1** (doc + guard only). **Amended C2** (dead-twin removal) and +> **C3** (4.5-seed quarantine into `experimental/`). Human-readable mirror of +> `tests/guards/orphan_inventory_guard_test.bats`, which pins the live-module set +> **and** that nothing under `experimental/` is ever sourced. **Any phase that +> changes the live-module set must update BOTH** this table and that guard's +> `EXPECTED_LIVE_MODULES`. Baseline `8c1897c`. + +After C3, `modules/` holds **exactly the 6 LIVE modules** — those reached by the +live install path (sourced from `actools.sh`, or referenced by the installer / +operator CLI). The **7 4.5-design seeds** that previously sat in `modules/` as +orphans (no live reference) were **moved to `experimental/`** in C3 — `git mv`, +content byte-identical, history preserved. (The 5 dead-twins — `health, migrate, +preflight, storage, worker` — were deleted in C2.) + +**This is a surface quarantine, not physical removal.** The install is in-place +(`INSTALL_DIR` = the repo dir, `actools.sh:94`) and `chown -R`'s the whole tree +(`actools.sh:405`), so the `experimental/` files still reside on the box — but +they are off the live surface, and the guard fails CI if any `experimental/…` +path is ever reached by the live closure or wired into an entry point. The +"unwired design reference" framing the operator docs already used (see +`enterprise.md`) is unchanged; only the paths moved to `experimental/`. + +**Totals: 6 live (`modules/`) · 7 quarantined 4.5-seed (`experimental/`).** +(Lineage: 18 module dirs at C1 → 12 orphan / 6 live; C2 deleted 5 dead-twins and +reclassified `ai`+`preview` as seeds → 13 dirs, 7 orphan; C3 moved the 7 seeds +to `experimental/` → `modules/` = 6, `experimental/` = 7.) + +| module | location | status | evidence | |---|---|---|---| -| `audit` | LIVE | referenced on the live path — `cli/actools:313,317` (operator-CLI surface, installed by copy) | — | -| `backup` | LIVE | sourced on the live path — `actools.sh:516` (`modules/backup/cron.sh`) | — | -| `db` | LIVE | sourced on the live path — `actools.sh:457` (`modules/db/core.sh`) | — | -| `drupal` | LIVE | sourced on the live path — `actools.sh:181` (`modules/drupal/provision.sh`) | — | -| `host` | LIVE | sourced on the live path — `actools.sh:193` loop over `modules/host/*.sh` | — | -| `stack` | LIVE | sourced on the live path — `actools.sh:204` loop over `modules/stack/*.sh` | — | -| `ai` | orphan · 4.5-seed (reclassified C2) | no live reference | C3: quarantine → `experimental/` | -| `preview` | orphan · 4.5-seed (reclassified C2) | no live reference | C3: quarantine → `experimental/` | -| `compliance` | orphan · 4.5-seed | no live reference | C3: quarantine → `experimental/` | -| `dr` | orphan · 4.5-seed | no live reference | C3: quarantine → `experimental/` | -| `network` | orphan · 4.5-seed | no live reference | C3: quarantine → `experimental/` | -| `observability` | orphan · 4.5-seed | no live reference | C3: quarantine → `experimental/` | -| `security` | orphan · 4.5-seed | no live reference | C3: quarantine → `experimental/` | - -The LIVE rows above are exactly the guard's `EXPECTED_LIVE_MODULES` -(`audit backup db drupal host stack`). The guard **derives** the actual set -from the tree — the source-closure of `actools.sh` (the `CLOSURE` engine from -`live_closure.bash`) **unioned** with `modules/` references in -`actools.sh` / `installer/` / `cli/actools` — and fails CI if it diverges from -this list in **either** direction (an undocumented new live module, or a -documented-live module that stops being sourced). `audit` is the one live -module reached without an `${INSTALL_DIR}` source line from `actools.sh`: it is -invoked from `cli/actools` (the copied operator-CLI surface), which is why the union with -the entry-point grep, not the closure alone, is required. +| `audit` | `modules/` | LIVE | live path — `cli/actools:313,317` (operator-CLI surface, installed by copy) | +| `backup` | `modules/` | LIVE | live path — `actools.sh:516` (`modules/backup/cron.sh`) | +| `db` | `modules/` | LIVE | live path — `actools.sh:457` (`modules/db/core.sh`) | +| `drupal` | `modules/` | LIVE | live path — `actools.sh:181` (`modules/drupal/provision.sh`) | +| `host` | `modules/` | LIVE | live path — `actools.sh:193` loop over `modules/host/*.sh` | +| `stack` | `modules/` | LIVE | live path — `actools.sh:204` loop over `modules/stack/*.sh` | +| `ai` | `experimental/` | quarantined 4.5-seed | no live reference (C3 move) | +| `compliance` | `experimental/` | quarantined 4.5-seed | no live reference (C3 move) | +| `dr` | `experimental/` | quarantined 4.5-seed | no live reference (C3 move) | +| `network` | `experimental/` | quarantined 4.5-seed | no live reference (C3 move) | +| `observability` | `experimental/` | quarantined 4.5-seed | no live reference (C3 move) | +| `preview` | `experimental/` | quarantined 4.5-seed | no live reference (C3 move) | +| `security` | `experimental/` | quarantined 4.5-seed | no live reference (C3 move) | + +The LIVE rows are exactly the guard's `EXPECTED_LIVE_MODULES` +(`audit backup db drupal host stack`). The guard **derives** the actual set from +the tree — the source-closure of `actools.sh` (the `CLOSURE` engine from +`live_closure.bash`) **unioned** with `modules/` references in `actools.sh` +/ `installer/` / `cli/actools` — and fails CI if it diverges in either +direction. A separate arm fails if any `experimental/…` path enters the live +closure. `audit` is the one live module reached without an `${INSTALL_DIR}` +source line from `actools.sh`: it is invoked from `cli/actools` (the copied +operator-CLI surface), which is why the union with the entry-point grep, not the +closure alone, is required. ## Update rule diff --git a/docs/enterprise.md b/docs/enterprise.md index 898428e..dfcad7c 100644 --- a/docs/enterprise.md +++ b/docs/enterprise.md @@ -1,6 +1,6 @@ # Enterprise Hardening (planned / experimental) -> **Status: design reference — NOT operational today.** This document describes **planned** enterprise and disaster-recovery features. The standard installer does **not** deploy them, and the commands shown throughout — `actools immortalize`, `actools resurrect`, `actools gdpr …`, `actools migrate --point-in-time …`, `actools backup status` — are **not registered** in the `actools` CLI: running them returns *unknown command*. The supporting scripts exist under `modules/backup/`, `modules/dr/`, and `modules/compliance/` but are unwired and unvalidated against the current stack. **Do not rely on the runbooks below in a real incident.** See [`../ROADMAP.md`](../ROADMAP.md) for status. +> **Status: design reference — NOT operational today.** This document describes **planned** enterprise and disaster-recovery features. The standard installer does **not** deploy them, and the commands shown throughout — `actools immortalize`, `actools resurrect`, `actools gdpr …`, `actools migrate --point-in-time …`, `actools backup status` — are **not registered** in the `actools` CLI: running them returns *unknown command*. The supporting scripts exist under `modules/backup/`, `experimental/dr/`, and `experimental/compliance/` but are unwired and unvalidated against the current stack. **Do not rely on the runbooks below in a real incident.** See [`../ROADMAP.md`](../ROADMAP.md) for status. The intent of this page: capture the target design for a future production-grade tier (~1 hour RPO, <15 minute RTO). Nothing here is part of the community installer today. @@ -33,7 +33,7 @@ The PITR scripts exist in `modules/backup/` but are not invoked by the standard ## DNA Resurrection *(planned — not wired; do not run)* -The design: `immortalize` would capture a complete server blueprint into an age-encrypted JSON snapshot, and `resurrect` would replay it on a fresh server. **Neither is a registered command.** The `modules/dr/resurrect.sh` script is unvalidated and would install a separate `actools-real` binary — **do not run it on a real server.** +The design: `immortalize` would capture a complete server blueprint into an age-encrypted JSON snapshot, and `resurrect` would replay it on a fresh server. **Neither is a registered command.** The `experimental/dr/resurrect.sh` script is unvalidated and would install a separate `actools-real` binary — **do not run it on a real server.** ```bash # (planned — not available) @@ -60,7 +60,7 @@ Never commit these to git. Store in a password manager or encrypted vault. ## GDPR Compliance *(planned — not wired)* -`actools gdpr …` is **not** a registered command. The code lives in `modules/compliance/gdpr.sh` and is unvalidated against the current Drupal version. +`actools gdpr …` is **not** a registered command. The code lives in `experimental/compliance/gdpr.sh` and is unvalidated against the current Drupal version. ```bash # (planned — not available) @@ -89,7 +89,7 @@ actools "2026-03-26 13:45:00" ### Server is dead — rebuild from scratch *(planned)* ```bash -# (planned — modules/dr/resurrect.sh is unwired/unvalidated; do not run on a real server) +# (planned — experimental/dr/resurrect.sh is unwired/unvalidated; do not run on a real server) ``` ### Handle a GDPR erasure request *(planned)* diff --git a/docs/hardening.md b/docs/hardening.md index 0f1b6da..ebc1e51 100644 --- a/docs/hardening.md +++ b/docs/hardening.md @@ -163,10 +163,10 @@ sudo -u actools actools drush prod cache:rebuild ### Sudoers rules -Rules are in `modules/security/sudoers-roles`. Deploy with: +Rules are in `experimental/security/sudoers-roles`. Deploy with: ```bash -sudo cp modules/security/sudoers-roles /etc/sudoers.d/actools-roles +sudo cp experimental/security/sudoers-roles /etc/sudoers.d/actools-roles sudo chmod 440 /etc/sudoers.d/actools-roles sudo visudo -c -f /etc/sudoers.d/actools-roles # validate before use ``` @@ -214,7 +214,7 @@ When Cloudflare Tunnel is configured, ports 2–4 will be removed — the server actools drush prod pm:security ``` -Weekly automated scan via cron (installs via `modules/security/`): +Weekly automated scan via cron (installs via `experimental/security/`): ```bash # /etc/cron.weekly/actools-security diff --git a/docs/observability.md b/docs/observability.md index ac00e63..045869c 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -75,10 +75,10 @@ node_filesystem_size_bytes > 0.85 ## Alerting -Prometheus alerting rules would live in `modules/observability/alerts.yml`. *(Note: this file is not present in the repo yet.)* To add an alert: +Prometheus alerting rules would live in `experimental/observability/alerts.yml`. *(Note: this file is not present in the repo yet.)* To add an alert: ```yaml -# modules/observability/alerts.yml +# experimental/observability/alerts.yml groups: - name: actools rules: diff --git a/docs/privacy.md b/docs/privacy.md index 1b1f8c7..9f0bfa9 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -2,7 +2,7 @@ ## AI Assistant *(experimental — not shipped)* -> The `actools ai` command is **not** registered in the CLI today; the code under `modules/ai/` is not wired in. The properties below describe the **intended** design for if/when it ships. +> The `actools ai` command is **not** registered in the CLI today; the code under `experimental/ai/` is not wired in. The properties below describe the **intended** design for if/when it ships. The planned Actools AI assistant (`actools ai`) is designed to run entirely on your server. diff --git a/docs/runbooks/PHASE0_LEDGER.md b/docs/runbooks/PHASE0_LEDGER.md index d3fac07..042559a 100644 --- a/docs/runbooks/PHASE0_LEDGER.md +++ b/docs/runbooks/PHASE0_LEDGER.md @@ -86,6 +86,59 @@ Approved / Needs revision / Blocked ### Forbidden next scope ```` +## Entry 023 — C3: quarantine the 7 4.5-seed modules into experimental/ + +Date: +Phase: C3 (Track C — cleanup) +Baseline: 8c1897c (#51) + +### Objective + +`git mv` the 7 committed 4.5-design seed modules out of `modules/` into +`experimental/` (`ai, compliance, dr, network, observability, preview, security` +— content byte-identical, history preserved) so `modules/` holds exactly the 6 +live modules (`audit, backup, db, drupal, host, stack`). This is a **surface +quarantine**: the install is in-place (`actools.sh:94`) and `chown -R`'s the +whole tree (`:405`), so the seeds still reside on the box, but they are off the +live surface and a new orphan-inventory guard arm fails CI if any +`experimental/…` path is ever reached by the live closure or wired into an entry +point. Also: removed the stale, ungated `.ai-context/full.txt` cache (its only +consumer, the unwired `experimental/ai/assistant.sh`, regenerates it on demand); +renamed the 3 seed shellcheck globs in `lint.yml` (`dr`/`observability`/`preview` +→ `experimental/`); fixed the seed-path pointers in 7 operator-facing docs; +landed the WORKFLOW-PACKAGE git-flag fixes (§3 `git am --check` +→ `git apply --check`; §7 `git am --reset-author` → plain `git am`, and dropped +the buggy `git apply` fallback line whose `git commit --reset-author` is invalid +without `--amend` — all three invalid on git 2.43, the third beyond the two C2 +identified); updated the runtime-authority-map +inventory; and ratified Entry 022. + +`technical-roadmap.md` (one stale `modules/compliance` heredoc path) is left to +**D1** (its overclaim-reconciliation phase). Historical records (CHANGELOG, +ledger 001–022 bodies, `docs/releases/*`, `HANDOFF-*`) and the seed *internals* +(unvalidated, never executed) are untouched. + +### Runtime authority changes + +**None to the live path.** No file in the live source-closure is modified; +`actools.sh` and the 6 live modules are byte-identical to `8c1897c`. The move +changes the file tree only. **Because it is a structural change (module tree + +lint + guard), a branch e2e green (`MariaDB ready.`) is a required pre-merge +gate** — insurance that the move is transparent to the installer. + +### Files + +Moved (git mv, 7 dirs / 11 files): `modules/{ai,compliance,dr,network,observability,preview,security}` → `experimental/`. New: `experimental/README.md`. Removed: `.ai-context/full.txt`. Edited: `.github/workflows/lint.yml`, `tests/guards/orphan_inventory_guard_test.bats` (1 arm added; `EXPECTED_LIVE_MODULES`/`derive_live_modules` byte-identical), `docs/architecture/runtime-authority-map.md`, `docs/advanced.md`, `docs/privacy.md`, `docs/enterprise.md`, `docs/hardening.md`, `docs/observability.md`, `ROADMAP.md`, `actools.env.example`, `docs/runbooks/WORKFLOW-PACKAGE.md`, this ledger. + +### Verdict + +**Pending** — the Review Gate ratifies on merge (this coding window does not +self-approve). Verify in order: see SPEC-C3 §6. + +### Commit SHA + +Sandbox commit(s) on top of `8c1897c`; operator stamps the squash/merge SHA on apply. + ## Entry 022 — C2 · Delete Dead-Twin Modules (5) + Reclassify ai/preview Date: 2026-06-14 @@ -245,7 +298,7 @@ PASS (all sandbox-runnable suites) — guard 2/2; non-vacuity inject→FAIL→re ### Review Gate decision -**Pending** — the Review Gate ratifies on merge (this coding window does not self-approve). Verify in order: scope (`diff` vs `ce35813` = exactly the 5 dirs deleted + the 4 files edited; `ai`/`preview` + the 5 seeds untouched) → deletion safety (re-grep the 5 names over the live path → 0; inline `migrate` guide intact at `cli/actools:282-291`) → guard integrity (`EXPECTED_LIVE_MODULES` + `derive_live_modules` byte-identical to `ce35813`; re-run 2/2; re-inject non-vacuity) → inventory truth (table matches the 13 dirs; ai/preview reclassified; totals correct; audit wording says "from `actools.sh`") → `lint.yml` (remaining shellcheck lines resolve; YAML parses) → no regression (full guards + generated green) → **branch e2e green (`MariaDB ready.`)** → patch reproduces the tree; author `actools-pl ` → then DOC-CHECK (`advanced.md`/`privacy.md` still accurate because ai/preview still exist). +**APPROVED — ratified (): C2 merged to `main` as `8c1897c` (#51); branch e2e #85 reached `MariaDB ready.` twice (stack bootstrap + prod Drupal install), confirming the 5 dead-twin deletions are transparent to the installer. `8c1897c` is the verified baseline of C3, and this ratification rides with the C3 patch — which re-runs the orphan-inventory guard green (closure-sanity + equality + the new experimental-quarantine arm) and depends on the C2 inventory.** *(Original pending text, for the record:)* **Pending** — the Review Gate ratifies on merge (this coding window does not self-approve). Verify in order: scope (`diff` vs `ce35813` = exactly the 5 dirs deleted + the 4 files edited; `ai`/`preview` + the 5 seeds untouched) → deletion safety (re-grep the 5 names over the live path → 0; inline `migrate` guide intact at `cli/actools:282-291`) → guard integrity (`EXPECTED_LIVE_MODULES` + `derive_live_modules` byte-identical to `ce35813`; re-run 2/2; re-inject non-vacuity) → inventory truth (table matches the 13 dirs; ai/preview reclassified; totals correct; audit wording says "from `actools.sh`") → `lint.yml` (remaining shellcheck lines resolve; YAML parses) → no regression (full guards + generated green) → **branch e2e green (`MariaDB ready.`)** → patch reproduces the tree; author `actools-pl ` → then DOC-CHECK (`advanced.md`/`privacy.md` still accurate because ai/preview still exist). ### Next safe task diff --git a/docs/runbooks/WORKFLOW-PACKAGE.md b/docs/runbooks/WORKFLOW-PACKAGE.md index 9a15e6a..7451124 100644 --- a/docs/runbooks/WORKFLOW-PACKAGE.md +++ b/docs/runbooks/WORKFLOW-PACKAGE.md @@ -56,7 +56,8 @@ pre-flight** — before any work: - **Baseline lock.** The bundle states the SHA; the window confirms `git log --oneline -1` == that SHA. **Mismatch → STOP** and report. -- **Inputs exist and apply.** `git apply --check` / `git am --check`; uploaded +- **Inputs exist and apply.** `git apply --check ` to test applicability + before `git am` (note: `git am --check` is *not* a valid flag on git 2.43); uploaded files present on disk. **Missing/non-applying → STOP** (this is the empty-upload and baseline-artifact surprise, prevented). - **Scope manifest = scope diff.** The spec's allowed/forbidden list must equal the @@ -122,10 +123,13 @@ The agent names leaked because `git am` preserves the *patch* author and/or a git -C ~/actoolsDrupal-src config user.name "actools-pl" git -C ~/actoolsDrupal-src config user.email "feezixmp@gmail.com" -# applying a coder's patch series — reset author to the committer (you): -git am --reset-author /tmp/patches/0*.patch -# single squashed patch: -git apply /tmp/.patch && git add -A && git commit --reset-author -m "…" +# applying a coder's patch series — the coder authors patches AS actools-pl, so PLAIN +# git am yields the correct author. NOTE: `git am --reset-author` is NOT a valid flag +# (git 2.43 errors "unknown option `reset-author'"); never pass it. Run the author-check +# (below) after applying; if it ever shows a wrong author, force it with +# `git commit --amend --reset-author --no-edit` (single commit — that flag IS valid on +# git commit, just not on git am). +git am /tmp/patches/0*.patch ``` - **GitHub squash-merge:** edit the squash message to **drop any `Co-authored-by:` diff --git a/experimental/README.md b/experimental/README.md new file mode 100644 index 0000000..7265b63 --- /dev/null +++ b/experimental/README.md @@ -0,0 +1,34 @@ +# experimental/ — quarantined 4.5-design seeds (NOT part of the live product) + +The modules here are committed **design seeds for Phase 4.5 features that are +NOT wired, NOT validated, and NOT part of the supported runtime surface.** They +were moved out of `modules/` in Phase C3 (`git mv modules/ experimental/`, +content byte-identical, history preserved) so the live set is unambiguous: +`modules/` now holds only the six modules the installer sources — `audit, +backup, db, drupal, host, stack`. + +## What "quarantined" means here +This is an **in-place install**: the repo directory *is* the install dir and the +installer `chown -R`'s the whole tree, so these files still reside on the box. +The move removes them from the **live surface**, not the filesystem. The +enforced, machine-checked guarantee is stronger: the orphan-inventory guard +(`tests/guards/orphan_inventory_guard_test.bats`) fails CI if any +`experimental/…` path is ever reached by the live install closure or wired into +`actools.sh` / `installer/` / `cli/`. Nothing here executes on the live path. + +## Contents (all unwired design reference) +- `ai/` — `actools ai …` (Ollama assistant). Not a registered command. +- `compliance/` — `actools gdpr …`. Not registered; unvalidated. +- `dr/` — `actools immortalize` / `resurrect`. **Do not run on a live server.** +- `network/` — Cloudflare tunnel setup doc/templates/service (DNS-01 / origin-cert). +- `observability/` — Prometheus/Grafana stack script (a referenced `alerts.yml` is not present). +- `preview/` — `actools branch …` ephemeral previews. Not registered. +- `security/` — audit-wrapper binary + sudoers-roles (RBAC seed). + +## Known-stale internals (for whoever wires these later) +Moved verbatim; some carry hardcoded paths the move did NOT update (out of C3 +scope — they never execute): +- `dr/resurrect.sh` copies `/home/actools/modules/security/…` (now `experimental/security/…`). +- `dr/immortalize.sh` and `ai/assistant.sh` glob `${INSTALL_DIR}/modules` (no longer + holds these seeds) and `cli/commands/*.sh` (mostly removed in P0-O). +Fix paths + re-validate before wiring any of these into the live CLI. diff --git a/modules/ai/assistant.sh b/experimental/ai/assistant.sh similarity index 100% rename from modules/ai/assistant.sh rename to experimental/ai/assistant.sh diff --git a/modules/compliance/gdpr.sh b/experimental/compliance/gdpr.sh similarity index 100% rename from modules/compliance/gdpr.sh rename to experimental/compliance/gdpr.sh diff --git a/modules/dr/immortalize.sh b/experimental/dr/immortalize.sh similarity index 100% rename from modules/dr/immortalize.sh rename to experimental/dr/immortalize.sh diff --git a/modules/dr/resurrect.sh b/experimental/dr/resurrect.sh similarity index 100% rename from modules/dr/resurrect.sh rename to experimental/dr/resurrect.sh diff --git a/modules/network/cloudflare-setup.md b/experimental/network/cloudflare-setup.md similarity index 100% rename from modules/network/cloudflare-setup.md rename to experimental/network/cloudflare-setup.md diff --git a/modules/network/cloudflared-config.yml.example b/experimental/network/cloudflared-config.yml.example similarity index 100% rename from modules/network/cloudflared-config.yml.example rename to experimental/network/cloudflared-config.yml.example diff --git a/modules/network/cloudflared.service b/experimental/network/cloudflared.service similarity index 100% rename from modules/network/cloudflared.service rename to experimental/network/cloudflared.service diff --git a/modules/observability/prometheus.sh b/experimental/observability/prometheus.sh similarity index 100% rename from modules/observability/prometheus.sh rename to experimental/observability/prometheus.sh diff --git a/modules/preview/branch.sh b/experimental/preview/branch.sh similarity index 100% rename from modules/preview/branch.sh rename to experimental/preview/branch.sh diff --git a/modules/security/actools-audit b/experimental/security/actools-audit similarity index 100% rename from modules/security/actools-audit rename to experimental/security/actools-audit diff --git a/modules/security/sudoers-roles b/experimental/security/sudoers-roles similarity index 100% rename from modules/security/sudoers-roles rename to experimental/security/sudoers-roles diff --git a/tests/guards/orphan_inventory_guard_test.bats b/tests/guards/orphan_inventory_guard_test.bats index 63f9878..34cb3a1 100644 --- a/tests/guards/orphan_inventory_guard_test.bats +++ b/tests/guards/orphan_inventory_guard_test.bats @@ -6,14 +6,14 @@ # reached by the live install path stops matching the canonical set recorded # below (and mirrored in docs/architecture/runtime-authority-map.md). Two drift # directions are caught: -# - an UNDOCUMENTED NEW live module — e.g. someone sources modules/ai/… on -# the live path → the derived set grows → this guard fails; +# - an UNDOCUMENTED NEW live module — e.g. someone sources a new +# `modules//…` on the live path → the derived set grows → this guard fails; # - a DOCUMENTED-LIVE module that STOPS being sourced → the derived set # shrinks → this guard fails. # -# This is the capture/guard-BEFORE-the-change for Track C: C2 (delete the -# dead-twin orphans) and C3 (quarantine the 4.5-seed orphans) are safe only -# because this guard freezes which modules are actually live. C1 makes NO code +# This is the capture/guard-BEFORE-the-change for Track C: C2 deleted the +# dead-twin orphans, and C3 quarantined the 4.5-seed orphans into `experimental/`; +# this guard's new arm pins that they never execute. C1 makes NO code # change and NO behaviour change — it adds this guard plus the human-readable # "Standalone modules" inventory in the runtime authority map. # @@ -34,11 +34,11 @@ # # NON-VACUOUS. A closure-sanity test (mirroring live_authority_guard_test.bats) # pins the closure engine so this guard cannot pass vacuously if the engine -# silently returns nothing. The equality test itself is demonstrably -# non-vacuous: injecting a modules/ai/… source line into a live-path file -# (e.g. actools.sh) makes the derived set include `ai`, so the equality -# assertion FAILS; reverting the injection makes it pass. (Inject→fail→ -# revert→pass is captured in HANDOFF-C1.md.) +# silently returns nothing. Both drift directions are demonstrably non-vacuous: +# injecting a `source modules//…` line (e.g. a throwaway `modules/zzz/`) +# grows the derived set → the equality arm FAILS; injecting a +# `source experimental//…` line trips the quarantine arm. Both +# inject→revert demos are captured in `HANDOFF-C3.md`. # # CI wiring: discovered by the recursive bats job (lint.yml: `bats -r tests/`). # ============================================================================= @@ -144,3 +144,30 @@ derive_live_modules() { return 1 fi } + +@test "no experimental/ path is reached by the live install closure (Phase C3 quarantine)" { + # SURFACE-QUARANTINE INVARIANT (C3). The 4.5-seed modules were moved out of + # modules/ into experimental/ (ai compliance dr network observability preview + # security). They are design-reference only and MUST NEVER execute on the live + # path. This arm fails if any change wires an experimental//… source line + # into the live closure, or references experimental/ from a live entry + # point (even before the target file exists on disk). + local hits + hits="$(printf '%s\n' "${CLOSURE[@]}" | grep -E '^experimental/' || true)" + [[ -z "$hits" ]] || { + echo "Live closure reaches into experimental/ — the seed quarantine is breached." + echo "Offending closure entries:"; printf ' %s\n' $hits + return 1 + } + + local refs + refs="$(grep -rhoE 'experimental/[A-Za-z0-9_]+' \ + "$REPO/actools.sh" "$REPO/installer" "$REPO/cli/actools" 2>/dev/null \ + | sort -u || true)" + [[ -z "$refs" ]] || { + echo "A live entry point references experimental/ — quarantine breached" + echo "(mirrors the entry-point union in derive_live_modules; catches a wired" + echo "reference whose target file may not yet exist):"; printf ' %s\n' $refs + return 1 + } +}