#!/usr/bin/env bash set -euo pipefail CONFIG_FILE="/opt/appfactory/config/appfactory.env" source "$CONFIG_FILE" APP_ID="${1:-}" if [ -z "$APP_ID" ]; then echo "Usage: ./deploy-app.sh " exit 1 fi export DOCKER_CONFIG="/tmp/.docker" mkdir -p "$DOCKER_CONFIG" DB_FILE="/opt/appfactory/data/appfactory/appfactory.db" APP_SOURCE_DIR="$WORKSPACE_DIR/$APP_ID" TOOLS_DIR="$WORKSPACE_DIR/appfactory-tools" APP_DATA_DIR="$APPFACTORY_DIR/data/apps/$APP_ID" APP_ENV_FILE="$APP_DATA_DIR/.env" TRIGGER_SOURCE="${APPFACTORY_TRIGGER_SOURCE:-cli}" TRIGGERED_BY_USER_ID="${APPFACTORY_TRIGGERED_BY_USER_ID:-}" TRIGGERED_BY_USERNAME="${APPFACTORY_TRIGGERED_BY_USERNAME:-}" TRIGGERED_BY_DISPLAY_NAME="${APPFACTORY_TRIGGERED_BY_DISPLAY_NAME:-}" COMMIT_AUTHOR="${APPFACTORY_COMMIT_AUTHOR:-}" PUSHER="${APPFACTORY_PUSHER:-}" if [ ! -d "$APP_SOURCE_DIR" ]; then echo "Missing app source: $APP_SOURCE_DIR" exit 1 fi if [ ! -f "$APP_SOURCE_DIR/Dockerfile" ]; then echo "Missing Dockerfile in: $APP_SOURCE_DIR" exit 1 fi DEPLOY_ID="$(python3 - "$DB_FILE" "$APP_ID" "$TRIGGER_SOURCE" "$TRIGGERED_BY_USER_ID" "$TRIGGERED_BY_USERNAME" "$TRIGGERED_BY_DISPLAY_NAME" "$COMMIT_AUTHOR" "$PUSHER" <<'PY' import sqlite3 import sys db_file = sys.argv[1] app_id = sys.argv[2] trigger_source = sys.argv[3] triggered_by_user_id = sys.argv[4] or None triggered_by_username = sys.argv[5] or None triggered_by_display_name = sys.argv[6] or None commit_author = sys.argv[7] or None pusher = sys.argv[8] or None con = sqlite3.connect(db_file) cur = con.execute( """ INSERT INTO deployments ( app_id, kind, status, started_at, trigger_source, triggered_by_user_id, triggered_by_username, triggered_by_display_name, commit_author, pusher ) VALUES (?, ?, ?, CURRENT_TIMESTAMP, ?, ?, ?, ?, ?, ?) """, ( app_id, "app", "running", trigger_source, triggered_by_user_id, triggered_by_username, triggered_by_display_name, commit_author, pusher, ), ) con.execute( """ UPDATE apps SET status = 'deploying', updated_at = CURRENT_TIMESTAMP WHERE id = ? """, (app_id,), ) con.commit() print(cur.lastrowid) con.close() PY )" TMP_DIR="$(mktemp -d)" STDOUT_FILE="$TMP_DIR/stdout.log" STDERR_FILE="$TMP_DIR/stderr.log" touch "$STDOUT_FILE" "$STDERR_FILE" finish_deploy() { local status="$1" local returncode="$2" python3 - "$DB_FILE" "$DEPLOY_ID" "$APP_ID" "$status" "$returncode" "$STDOUT_FILE" "$STDERR_FILE" <<'PY' import sqlite3 import sys from pathlib import Path db_file = sys.argv[1] deploy_id = sys.argv[2] app_id = sys.argv[3] status = sys.argv[4] returncode = int(sys.argv[5]) stdout_file = Path(sys.argv[6]) stderr_file = Path(sys.argv[7]) stdout = stdout_file.read_text(errors="replace") if stdout_file.exists() else "" stderr = stderr_file.read_text(errors="replace") if stderr_file.exists() else "" app_status = "deployed" if status == "success" else "failed" con = sqlite3.connect(db_file) con.execute( """ UPDATE deployments SET status = ?, finished_at = CURRENT_TIMESTAMP, stdout = ?, stderr = ?, returncode = ? WHERE id = ? """, (status, stdout, stderr, returncode, deploy_id), ) con.execute( """ UPDATE apps SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? """, (app_status, app_id), ) con.commit() con.close() PY } run_step() { local title="$1" shift { echo "" echo "==================================================" echo "$title" echo "==================================================" "$@" } >>"$STDOUT_FILE" 2>>"$STDERR_FILE" } set +e run_step "Updating source code" bash -c " cd '$APP_SOURCE_DIR' if [ -d '.git' ]; then git -c safe.directory='$APP_SOURCE_DIR' fetch origin main git -c safe.directory='$APP_SOURCE_DIR' reset --hard origin/main fi " RESULT=$? if [ "$RESULT" -eq 0 ]; then run_step "Generating app environment file" bash -c " mkdir -p '$APP_DATA_DIR' python3 - '$DB_FILE' '$APP_ID' '$APP_ENV_FILE' <<'PY' import os import re import sqlite3 import sys from pathlib import Path db_file = sys.argv[1] app_id = sys.argv[2] env_file = Path(sys.argv[3]) key_pattern = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$') con = sqlite3.connect(db_file) con.row_factory = sqlite3.Row rows = con.execute( ''' SELECT key, value, is_secret, required FROM app_variables WHERE app_id = ? ORDER BY key ''', (app_id,), ).fetchall() missing_required = [] lines = [ '# Generated by AppFactory. Do not edit manually.', f'APP_ID={app_id}', ] for row in rows: key = row['key'] value = row['value'] or '' required = int(row['required'] or 0) if not key_pattern.match(key): raise SystemExit(f'Invalid environment variable key: {key}') if required and value == '': missing_required.append(key) escaped = value.replace('\\\\', '\\\\\\\\').replace('\"', '\\\\\"').replace('\\n', '\\\\n') lines.append(f'{key}=\"{escaped}\"') if missing_required: raise SystemExit('Missing required variables: ' + ', '.join(missing_required)) env_file.parent.mkdir(parents=True, exist_ok=True) env_file.write_text('\\n'.join(lines) + '\\n', encoding='utf-8') os.chmod(env_file, 0o600) con.close() PY echo 'Generated env file: $APP_ENV_FILE' " RESULT=$? fi if [ "$RESULT" -eq 0 ]; then run_step "Building Docker image" \ docker build --pull --no-cache \ -t "$APP_ID:latest" \ "$APP_SOURCE_DIR" RESULT=$? fi if [ "$RESULT" -eq 0 ]; then run_step "Regenerating generated files" bash -c " '$TOOLS_DIR/scripts/generate-catalog.sh' '$TOOLS_DIR/scripts/generate-apps-compose.sh' '$TOOLS_DIR/scripts/generate-caddyfile.sh' '$TOOLS_DIR/scripts/generate-apps-page.sh' " RESULT=$? fi if [ "$RESULT" -eq 0 ]; then run_step "Starting app container" bash -c " cd '$APPFACTORY_DIR/deploy' docker compose \ -f docker-compose.yml \ -f apps-compose.yml \ up -d --force-recreate '$APP_ID' " RESULT=$? fi set -e if [ "$RESULT" -eq 0 ]; then finish_deploy "success" 0 cat "$STDOUT_FILE" cat "$STDERR_FILE" >&2 echo "" echo "DONE" echo "Open:" echo "$APPFACTORY_PUBLIC_URL/apps/$APP_ID/docs" else finish_deploy "failed" "$RESULT" cat "$STDOUT_FILE" cat "$STDERR_FILE" >&2 echo "" echo "FAILED" echo "Deployment ID: $DEPLOY_ID" rm -rf "$TMP_DIR" exit "$RESULT" fi rm -rf "$TMP_DIR"