Files
UmbraAudio/infra/bootstrap_umbracontrol_database.sh
2026-10-18 19:01:04 -07:00

372 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# Copyright 2026 Umbra Tools (umbratools.com)
# SPDX-License-Identifier: Apache-2.0
# Database-only bootstrap for the UmbraTrans control plane.
# This intentionally does not install packages, create application directories,
# install application services, or modify Qwen/Docker/firewall settings.
set -Eeuo pipefail
umask 077
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
CONFIG_FILE="${UMBRA_BOOTSTRAP_CONFIG:-${SCRIPT_DIR}/enterprise-bootstrap.env}"
MODE="check"
DRY_RUN=0
LOCK_DIR="/run/lock/umbracontrol-database.lock"
LOG_FILE="/var/log/umbracontrol-database-bootstrap.log"
PG_SOCKET_DIR="/var/run/postgresql"
UMBRA_DB_NAME="umbracontrol"
UMBRA_DB_HOST="127.0.0.1"
UMBRA_DB_PORT="5432"
UMBRA_BOOTSTRAP_ADMIN_EMAIL="umbratools@gmail.com"
UMBRA_ORGANIZATION_NAME="Umbra"
UMBRA_ORGANIZATION_SLUG="umbra"
UMBRA_PG_SUPERUSER="postgres"
UMBRA_CREDENTIALS_FILE="/etc/umbracontrol/db-credentials.env"
UMBRA_BOOTSTRAP_TOKEN_FILE="/root/umbra-enterprise-bootstrap.token"
UMBRA_SERVICE_GROUP="umbracontrol"
UMBRA_OWNER_ROLE="umbracontrol_owner"
UMBRA_MIGRATOR_ROLE="umbracontrol_migrator"
UMBRA_API_ROLE="umbracontrol_api"
UMBRA_WORKER_ROLE="umbracontrol_worker"
UMBRA_READONLY_ROLE="umbracontrol_readonly"
UMBRA_BACKUP_ROLE="umbracontrol_backup"
usage() {
cat <<'EOF'
Usage: bootstrap_umbracontrol_database.sh [options]
Options:
--check Read-only PostgreSQL and prerequisite inspection
--dry-run Show database actions without changing the host
--apply Create/finish the database, roles, schema, and bootstrap admin
--verify Verify the database, schema, RLS, and bootstrap records
--config FILE Read the existing non-secret enterprise-bootstrap.env
-h, --help Show this help
This script is intentionally database-only. It does not run apt, install uv,
create application directories, start Qwen, or change firewall rules.
EOF
}
while (($#)); do
case "$1" in
--check) MODE="check"; shift ;;
--dry-run) MODE="apply"; DRY_RUN=1; shift ;;
--apply) MODE="apply"; DRY_RUN=0; shift ;;
--verify) MODE="verify"; shift ;;
--config) CONFIG_FILE="${2:?--config requires a file}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
log() {
local line="[$(date -Is)] $*"
printf '%s\n' "$line"
if [[ -f "$LOG_FILE" && "$(id -u)" -eq 0 ]]; then
printf '%s\n' "$line" >> "$LOG_FILE"
fi
}
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
require_root() {
[[ "$(id -u)" -eq 0 ]] || die "This operation requires root. Use --check for read-only inspection."
}
require_command() {
command -v "$1" >/dev/null 2>&1 || die "Required command is missing: $1"
}
load_config() {
[[ -f "$CONFIG_FILE" ]] || {
[[ "$MODE" == "check" || "$MODE" == "verify" ]] && return 0
die "Configuration file not found: $CONFIG_FILE"
}
local line key value
while IFS= read -r line || [[ -n "$line" ]]; do
line="${line#"${line%%[![:space:]]*}"}"
[[ -z "$line" || "${line:0:1}" == "#" ]] && continue
[[ "$line" == *=* ]] || die "Invalid configuration line: $line"
key="${line%%=*}"
value="${line#*=}"
key="${key//[[:space:]]/}"
value="${value%\"}"; value="${value#\"}"
value="${value%\'}"; value="${value#\'}"
case "$key" in
UMBRA_DB_NAME|UMBRA_DB_HOST|UMBRA_DB_PORT|UMBRA_BOOTSTRAP_ADMIN_EMAIL|UMBRA_ORGANIZATION_NAME|UMBRA_ORGANIZATION_SLUG)
printf -v "$key" '%s' "$value"
;;
UMBRA_*)
: # Ignore unrelated non-secret enterprise settings.
;;
*)
die "Unsupported configuration key: $key"
;;
esac
done < "$CONFIG_FILE"
}
validate_config() {
[[ "$UMBRA_DB_NAME" =~ ^[a-z_][a-z0-9_]*$ ]] || die "Invalid UMBRA_DB_NAME"
[[ "$UMBRA_DB_HOST" =~ ^[A-Za-z0-9._:-]+$ ]] || die "Invalid UMBRA_DB_HOST"
[[ "$UMBRA_DB_PORT" =~ ^[0-9]+$ && "$UMBRA_DB_PORT" -ge 1 && "$UMBRA_DB_PORT" -le 65535 ]] || die "Invalid UMBRA_DB_PORT"
[[ "$UMBRA_BOOTSTRAP_ADMIN_EMAIL" == *@*.* ]] || die "Invalid administrator email"
[[ "$UMBRA_ORGANIZATION_SLUG" =~ ^[a-z0-9][a-z0-9-]*$ ]] || die "Invalid organization slug"
[[ -d "$PG_SOCKET_DIR" ]] || die "PostgreSQL socket directory is missing: $PG_SOCKET_DIR"
}
run() {
if ((DRY_RUN)); then
printf '[dry-run]'
printf ' %q' "$@"
printf '\n'
else
"$@"
fi
}
random_secret() {
openssl rand -hex 32
}
read_secret() {
local key="$1"
[[ -f "$UMBRA_CREDENTIALS_FILE" ]] || return 1
awk -F= -v key="$key" '$1 == key {print substr($0, index($0, "=") + 1); exit}' "$UMBRA_CREDENTIALS_FILE"
}
write_secret() {
local key="$1" value="$2" temporary
if ((DRY_RUN)); then
printf '[dry-run] store generated database secret %s\n' "$key"
return
fi
install -d -m 0750 -o root -g "$UMBRA_SERVICE_GROUP" "$(dirname "$UMBRA_CREDENTIALS_FILE")"
touch "$UMBRA_CREDENTIALS_FILE"
chown root:"$UMBRA_SERVICE_GROUP" "$UMBRA_CREDENTIALS_FILE"
chmod 0640 "$UMBRA_CREDENTIALS_FILE"
temporary="$(mktemp "${UMBRA_CREDENTIALS_FILE}.XXXXXX")"
awk -F= -v key="$key" -v value="$value" '
$1 == key { print key "=" value; found = 1; next }
{ print }
END { if (!found) print key "=" value }
' "$UMBRA_CREDENTIALS_FILE" > "$temporary"
chown root:"$UMBRA_SERVICE_GROUP" "$temporary"
chmod 0640 "$temporary"
mv -f "$temporary" "$UMBRA_CREDENTIALS_FILE"
}
psql_as_postgres() {
runuser -u "$UMBRA_PG_SUPERUSER" -- env \
-u PGHOST -u PGPORT -u PGDATABASE -u PGUSER -u PGPASSWORD \
-u PGPASSWORDFILE -u PGSERVICE -u PGSERVICEFILE \
PGOPTIONS='-c log_statement=none' \
psql -h "$PG_SOCKET_DIR" -p "$UMBRA_DB_PORT" -U "$UMBRA_PG_SUPERUSER" \
-v ON_ERROR_STOP=1 "$@"
}
role_exists() {
local role="$1"
psql_as_postgres postgres -tAc "SELECT 1 FROM pg_roles WHERE rolname = '$role'" | grep -q 1
}
ensure_role() {
local role="$1" login="$2" password_key="$3" password=""
if ((DRY_RUN)); then
printf '[dry-run] ensure PostgreSQL role %s (%s)\n' "$role" "$login"
return
fi
if role_exists "$role"; then
if [[ "$login" == "LOGIN" ]]; then
printf 'ALTER ROLE "%s" LOGIN;\n' "$role" | psql_as_postgres postgres >/dev/null
else
printf 'ALTER ROLE "%s" NOLOGIN;\n' "$role" | psql_as_postgres postgres >/dev/null
fi
log "PostgreSQL role already exists: $role"
return
fi
if [[ "$login" == "LOGIN" ]]; then
password="$(read_secret "$password_key" 2>/dev/null || true)"
[[ -n "$password" ]] || password="$(random_secret)"
write_secret "$password_key" "$password"
printf 'CREATE ROLE "%s" LOGIN PASSWORD '\''%s'\'';\n' "$role" "$password" \
| psql_as_postgres postgres >/dev/null
else
printf 'CREATE ROLE "%s" NOLOGIN;\n' "$role" | psql_as_postgres postgres >/dev/null
fi
log "PostgreSQL role created: $role"
}
ensure_roles() {
log "Ensuring PostgreSQL roles"
ensure_role "$UMBRA_OWNER_ROLE" NOLOGIN OWNER_UNUSED
ensure_role "$UMBRA_MIGRATOR_ROLE" LOGIN UMBRACONTROL_MIGRATOR_PASSWORD
ensure_role "$UMBRA_API_ROLE" LOGIN UMBRACONTROL_API_PASSWORD
ensure_role "$UMBRA_WORKER_ROLE" LOGIN UMBRACONTROL_WORKER_PASSWORD
ensure_role "$UMBRA_READONLY_ROLE" LOGIN UMBRACONTROL_READONLY_PASSWORD
ensure_role "$UMBRA_BACKUP_ROLE" LOGIN UMBRACONTROL_BACKUP_PASSWORD
}
database_exists() {
psql_as_postgres postgres -tAc "SELECT 1 FROM pg_database WHERE datname = '$UMBRA_DB_NAME'" | grep -q 1
}
ensure_database() {
log "Ensuring database: $UMBRA_DB_NAME"
if ((DRY_RUN)); then
printf '[dry-run] create database if absent, owner=%s\n' "$UMBRA_OWNER_ROLE"
return
fi
if ! database_exists; then
runuser -u "$UMBRA_PG_SUPERUSER" -- createdb --owner="$UMBRA_OWNER_ROLE" "$UMBRA_DB_NAME"
log "Database created"
else
log "Database already exists"
fi
psql_as_postgres postgres -c "REVOKE ALL ON DATABASE \"$UMBRA_DB_NAME\" FROM PUBLIC" >/dev/null
psql_as_postgres postgres -c "GRANT CONNECT ON DATABASE \"$UMBRA_DB_NAME\" TO \"$UMBRA_MIGRATOR_ROLE\", \"$UMBRA_API_ROLE\", \"$UMBRA_WORKER_ROLE\", \"$UMBRA_READONLY_ROLE\", \"$UMBRA_BACKUP_ROLE\"" >/dev/null
}
apply_schema() {
local schema_file="${SCRIPT_DIR}/postgres/001_control_plane.sql"
[[ -f "$schema_file" ]] || die "Schema file is missing: $schema_file"
log "Applying identity, tenancy, role, audit, and RLS schema"
if ((DRY_RUN)); then
printf '[dry-run] stream %s into PostgreSQL database %s\n' "$schema_file" "$UMBRA_DB_NAME"
return
fi
/bin/cat -- "$schema_file" | psql_as_postgres "$UMBRA_DB_NAME" >/dev/null
log "Database schema applied"
}
create_bootstrap_admin() {
local token token_hash
if [[ -f "$UMBRA_BOOTSTRAP_TOKEN_FILE" ]]; then
token="$(cat "$UMBRA_BOOTSTRAP_TOKEN_FILE")"
else
token="$(random_secret)"
if ((DRY_RUN)); then
printf '[dry-run] create root-only bootstrap token\n'
return
fi
printf '%s\n' "$token" > "$UMBRA_BOOTSTRAP_TOKEN_FILE"
chown root:root "$UMBRA_BOOTSTRAP_TOKEN_FILE"
chmod 0600 "$UMBRA_BOOTSTRAP_TOKEN_FILE"
fi
[[ "$token" =~ ^[a-f0-9]{64}$ ]] || die "Bootstrap token has an unexpected format"
token_hash="$(printf '%s' "$token" | sha256sum | awk '{print $1}')"
log "Ensuring bootstrap administrator"
if ((DRY_RUN)); then
printf '[dry-run] create administrator %s and organization %s\n' "$UMBRA_BOOTSTRAP_ADMIN_EMAIL" "$UMBRA_ORGANIZATION_NAME"
return
fi
psql_as_postgres "$UMBRA_DB_NAME" \
-v admin_email="$UMBRA_BOOTSTRAP_ADMIN_EMAIL" \
-v token_hash="$token_hash" \
-v organization_name="$UMBRA_ORGANIZATION_NAME" \
-v organization_slug="$UMBRA_ORGANIZATION_SLUG" <<'SQL' >/dev/null
INSERT INTO users (email, display_name, auth_provider, auth_subject, status, mfa_required)
VALUES (:'admin_email', :'admin_email', 'bootstrap', 'bootstrap:' || gen_random_uuid()::text, 'invited', true)
ON CONFLICT (email) DO UPDATE
SET display_name = EXCLUDED.display_name,
auth_provider = 'bootstrap',
mfa_required = true,
updated_at = now();
INSERT INTO organizations (name, slug)
VALUES (:'organization_name', :'organization_slug')
ON CONFLICT (slug) DO UPDATE SET name = EXCLUDED.name;
INSERT INTO user_roles (user_id, role_id, scope, organization_id, project_id, created_by)
SELECT u.id, r.id, 'platform', NULL, NULL, u.id
FROM users u
JOIN roles r ON r.scope = 'platform' AND r.name = 'platform_admin'
WHERE u.email = :'admin_email'
ON CONFLICT DO NOTHING;
INSERT INTO user_roles (user_id, role_id, scope, organization_id, project_id, created_by)
SELECT u.id, r.id, 'organization', o.id, NULL, u.id
FROM users u
JOIN organizations o ON o.slug = :'organization_slug'
JOIN roles r ON r.scope = 'organization' AND r.name = 'organization_admin'
WHERE u.email = :'admin_email'
ON CONFLICT DO NOTHING;
INSERT INTO bootstrap_tokens (user_id, token_hash, expires_at)
SELECT id, :'token_hash', now() + interval '24 hours'
FROM users
WHERE email = :'admin_email'
ON CONFLICT (token_hash) DO NOTHING;
SQL
log "Bootstrap administrator is ready"
}
check_mode() {
log "Database-only read-only inspection"
printf 'psql: '; command -v psql 2>/dev/null || echo 'missing'
printf 'PostgreSQL: '; command -v pg_lsclusters >/dev/null 2>&1 && pg_lsclusters || echo 'not installed'
printf 'service: '; systemctl is-active postgresql 2>/dev/null || true
printf 'socket: '; test -S "$PG_SOCKET_DIR/.s.PGSQL.$UMBRA_DB_PORT" && echo ready || echo missing
printf 'database socket: '; pg_isready -h "$PG_SOCKET_DIR" -p "$UMBRA_DB_PORT" -d "$UMBRA_DB_NAME" 2>&1 || true
printf 'roles: '; getent group "$UMBRA_SERVICE_GROUP" 2>/dev/null || echo 'service group absent'
log "No changes made"
}
apply_mode() {
if ((DRY_RUN == 0)); then
require_root
mkdir "$LOCK_DIR" 2>/dev/null || die "Another database bootstrap is running"
trap 'rmdir "$LOCK_DIR" 2>/dev/null || true' EXIT
touch "$LOG_FILE"
chmod 0640 "$LOG_FILE"
else
log "Database-only bootstrap dry-run"
fi
ensure_roles
ensure_database
apply_schema
create_bootstrap_admin
log "Database-only bootstrap completed"
}
verify_mode() {
require_root
log "Verifying database-only bootstrap"
pg_isready -h "$UMBRA_DB_HOST" -p "$UMBRA_DB_PORT" -d "$UMBRA_DB_NAME"
psql_as_postgres "$UMBRA_DB_NAME" -v ON_ERROR_STOP=1 \
-c 'SELECT version, applied_at FROM schema_migrations ORDER BY version' \
-c 'SELECT count(*) AS organizations FROM organizations' \
-c 'SELECT count(*) AS users FROM users' \
-c "SELECT count(*) AS rls_tables FROM pg_class WHERE relrowsecurity AND relname IN ('organizations','projects','users','user_roles','invitations','sessions','mfa_methods','bootstrap_tokens','audit_events')"
log "Database verification completed"
}
main() {
load_config
validate_config
require_command openssl
require_command systemctl
if [[ "$MODE" == "apply" || "$MODE" == "verify" ]]; then
require_command psql
require_command pg_isready
require_command runuser
fi
case "$MODE" in
check) check_mode ;;
apply) apply_mode ;;
verify) verify_mode ;;
*) die "Unknown mode: $MODE" ;;
esac
}
main