Files
Cibello-app/infrastructure/deployment/first-deploy.sh
T
Sven (AAMOS AI) 6e2d794130 feat(storage,deploy): delad S3/mock-lagring, SSM .env-sync, Gemini readyz
- @app/storage med MockStorage + AwsStorage delas mellan API och worker

- Workerns readUrl() ger presignerad S3-URL i aws-läge (fixar mock-s3 404)

- sync-env-from-ssm.py hämtar /cibello/prod/* och skriver .env; first-deploy.sh kör det i prod

- /readyz returnerar 503 om app.aamos.healthCheck() misslyckas

- docs/SECRETS.md med rotations- och SSM-regler
2026-08-12 17:28:58 +07:00

332 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ============================================================================
# FÖRSTA DEPLOY idempotent uppsättning av hela plattformen på en server.
#
# DEPLOY_MODE=staging ./infrastructure/deployment/first-deploy.sh
# DEPLOY_MODE=production ./infrastructure/deployment/first-deploy.sh
#
# STAGING (default): startar Postgres+Redis i Docker, genererar hemligheter
# om de saknas, AAMOS_MODE=mock tillåtet, seedar databasen, kör hela
# verifieringskedjan. Säker att köra om varje steg är idempotent.
#
# PRODUCTION: kräver befintlig .env med riktiga värden (skriptet VÄGRAR
# generera produktionshemligheter), kräver AAMOS_MODE=http, tar backup
# före migrationer, seedar ALDRIG automatiskt.
#
# Avslutar med exitkod 0 endast när ALLA grindar är gröna.
# ============================================================================
set -euo pipefail
MODE="${DEPLOY_MODE:-staging}"
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
cd "$ROOT"
LOG_PREFIX="[first-deploy:$MODE]"
say() { echo "$LOG_PREFIX $*"; }
fail() { echo "$LOG_PREFIX FEL: $*" >&2; exit 1; }
say "Startar i läge: $MODE (repo: $ROOT)"
# ---------------------------------------------------------------------------
# GRIND 0: Förutsättningar
# ---------------------------------------------------------------------------
# Docker är VALFRITT: det används bara som bekvämlighet för att starta
# Postgres/Redis i staging när de inte redan svarar. Kör tjänsterna nativt
# (WSL, systemd, molndatabas) behövs ingen Docker alls Grind 2 avgör på
# faktisk nåbarhet, inte på verktygslistan.
if command -v docker >/dev/null && docker compose version >/dev/null 2>&1; then
DOCKER_OK=1
DOCKER_NOTE="docker+compose"
else
DOCKER_OK=0
DOCKER_NOTE="utan docker (ok om Postgres/Redis redan kör)"
say "OBS: docker saknas ingen fara om Postgres/Redis redan svarar (nativt/WSL/moln). Grind 2 verifierar."
fi
command -v node >/dev/null || fail "node saknas kräver Node >= 22."
NODE_MAJOR=$(node -e "console.log(process.versions.node.split('.')[0])")
[ "$NODE_MAJOR" -ge 22 ] || fail "Node $NODE_MAJOR är för gammal kräver >= 22."
command -v pnpm >/dev/null || fail "pnpm saknas kör: corepack enable && corepack prepare pnpm@10 --activate"
say "GRIND 0 OK: $DOCKER_NOTE, node $NODE_MAJOR, pnpm $(pnpm --version)"
# ---------------------------------------------------------------------------
# GRIND 1: Miljöfil
# ---------------------------------------------------------------------------
if [ ! -f .env ]; then
if [ "$MODE" = "production" ]; then
fail ".env saknas. I produktion skapas den manuellt från .env.example med riktiga värden (Secrets Manager). Skriptet genererar ALDRIG produktionshemligheter."
fi
say "Ingen .env skapar staging-miljö med genererade hemligheter."
cp .env.example .env
gen() { node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"; }
# Ersätt dev-platshållare med riktiga slumpvärden (även staging förtjänar riktiga hemligheter).
sed -i "s|^JWT_ACCESS_SECRET=.*|JWT_ACCESS_SECRET=$(gen)|" .env
sed -i "s|^JWT_REFRESH_SECRET=.*|JWT_REFRESH_SECRET=$(gen)|" .env
sed -i "s|^ENTITLEMENT_SIGNING_SECRET=.*|ENTITLEMENT_SIGNING_SECRET=$(gen)|" .env
# AAMOS: riktiga värden via miljövariabler vid körning vinner över mock.
if [ -n "${AAMOS_API_URL:-}" ] && [ -n "${AAMOS_API_KEY:-}" ]; then
sed -i "s|^AAMOS_MODE=.*|AAMOS_MODE=http|" .env
sed -i "s|^AAMOS_API_URL=.*|AAMOS_API_URL=$AAMOS_API_URL|" .env
sed -i "s|^AAMOS_API_KEY=.*|AAMOS_API_KEY=$AAMOS_API_KEY|" .env
say "AAMOS: riktiga uppgifter angivna kör http-läge från start."
else
sed -i "s|^AAMOS_MODE=.*|AAMOS_MODE=mock|" .env
fi
# E-post: riktig SMTP från start om uppgifter angivits.
if [ -n "${SMTP_HOST:-}" ] && [ -n "${MAIL_FROM:-}" ]; then
sed -i "s|^EMAIL_MODE=.*|EMAIL_MODE=smtp|" .env
sed -i "s|^SMTP_HOST=.*|SMTP_HOST=$SMTP_HOST|" .env
sed -i "s|^SMTP_PORT=.*|SMTP_PORT=${SMTP_PORT:-587}|" .env
sed -i "s|^SMTP_USER=.*|SMTP_USER=${SMTP_USER:-}|" .env
sed -i "s|^SMTP_PASS=.*|SMTP_PASS=${SMTP_PASS:-}|" .env
sed -i "s|^MAIL_FROM=.*|MAIL_FROM=$MAIL_FROM|" .env
say "E-post: SMTP-uppgifter angivna riktiga mejl från start."
fi
# Fillagring: riktig S3 från start om bucket angivits.
if [ -n "${S3_BUCKET:-}" ]; then
sed -i "s|^S3_MODE=.*|S3_MODE=aws|" .env
sed -i "s|^S3_BUCKET=.*|S3_BUCKET=$S3_BUCKET|" .env
[ -n "${S3_REGION:-}" ] && sed -i "s|^S3_REGION=.*|S3_REGION=$S3_REGION|" .env
[ -n "${S3_ENDPOINT:-}" ] && sed -i "s|^S3_ENDPOINT=.*|S3_ENDPOINT=$S3_ENDPOINT|" .env
[ -n "${S3_ACCESS_KEY_ID:-}" ] && sed -i "s|^S3_ACCESS_KEY_ID=.*|S3_ACCESS_KEY_ID=$S3_ACCESS_KEY_ID|" .env
[ -n "${S3_SECRET_ACCESS_KEY:-}" ] && sed -i "s|^S3_SECRET_ACCESS_KEY=.*|S3_SECRET_ACCESS_KEY=$S3_SECRET_ACCESS_KEY|" .env
say "Fillagring: S3-uppgifter angivna riktig lagring från start."
fi
chmod 600 .env
fi
# I produktion härleds .env alltid ur SSM inga handkopior får drifta.
if [ "$MODE" = "production" ]; then
if [ -f "$ROOT/infrastructure/deployment/sync-env-from-ssm.py" ]; then
say "Synkar .env från SSM..."
python3 "$ROOT/infrastructure/deployment/sync-env-from-ssm.py"
else
fail "sync-env-from-ssm.py saknas kan inte synka .env från SSM."
fi
fi
get_env() { { grep -E "^$1=" .env || true; } | head -1 | cut -d= -f2-; }
AAMOS_MODE_VAL=$(get_env AAMOS_MODE)
# Sätt NODE_EXTRA_CA_CERTS om definierad i .env (behövs för RDS SSL från Node/pg).
EXTRA_CA=$(get_env NODE_EXTRA_CA_CERTS)
[ -n "$EXTRA_CA" ] && export NODE_EXTRA_CA_CERTS="$EXTRA_CA"
if [ "$MODE" = "production" ]; then
if [ "$AAMOS_MODE_VAL" = "gemini" ]; then
[ -n "$(get_env GEMINI_API_KEY)" ] || fail "Produktion kräver GEMINI_API_KEY när AAMOS_MODE=gemini."
else
[ "$AAMOS_MODE_VAL" = "http" ] || fail "Produktion kräver AAMOS_MODE=http eller gemini (nu: '$AAMOS_MODE_VAL')."
[ -n "$(get_env AAMOS_API_URL)" ] || fail "AAMOS_API_URL saknas i .env."
[ -n "$(get_env AAMOS_API_KEY)" ] || fail "AAMOS_API_KEY saknas i .env."
fi
for s in JWT_ACCESS_SECRET JWT_REFRESH_SECRET ENTITLEMENT_SIGNING_SECRET; do
case "$(get_env $s)" in dev-only-*|"") fail "$s har dev-/tomt värde sätt riktig hemlighet." ;; esac
done
fi
say "GRIND 1 OK: .env på plats ($([ "$MODE" = production ] && echo 'produktionsvärden verifierade' || echo 'staging'))"
# ---------------------------------------------------------------------------
# GRIND 2: Databas + Redis (nåbarhet avgör Docker är bara reservstart)
# ---------------------------------------------------------------------------
db_reachable() {
(cd "$ROOT" && node -e "
const { Client } = require('pg');
const fs = require('fs');
const caPath = process.env.DATABASE_CA_CERT || process.env.NODE_EXTRA_CA_CERTS;
const rawUrl = process.env.DB_URL || '';
const hasSslmode = /sslmode=(require|verify)/.test(rawUrl);
const url = rawUrl.replace(/[?&]sslmode=[^&]+/, '').replace(/\?$/, '');
let ssl;
if (caPath && fs.existsSync(caPath)) {
ssl = { ca: fs.readFileSync(caPath), rejectUnauthorized: true };
} else if (hasSslmode) {
ssl = { rejectUnauthorized: false };
}
const c = new Client({ connectionString: url, connectionTimeoutMillis: 3000, ...(ssl ? { ssl } : {}) });
c.connect().then(() => c.end()).then(() => process.exit(0)).catch((e) => { console.error(e.message); process.exit(1); });
") 2>/dev/null
}
# Rå TCP-PING: alla svar (även -NOAUTH) bevisar att Redis är nåbar.
redis_reachable() {
node -e "
const net = require('net');
let u; try { u = new URL(process.env.R_URL); } catch { process.exit(1); }
const s = net.createConnection({ host: u.hostname || '127.0.0.1', port: Number(u.port) || 6379 });
s.setTimeout(3000);
s.on('connect', () => s.write('PING\r\n'));
s.on('data', () => process.exit(0));
s.on('error', () => process.exit(1));
s.on('timeout', () => process.exit(1));
" 2>/dev/null
}
start_docker_services() {
# Reservstart i staging kräver Docker. Utan Docker: starta tjänsterna själv.
if [ "$DOCKER_OK" != 1 ] || ! docker info >/dev/null 2>&1; then
fail "$1 svarar inte och Docker är inte tillgänglig. Starta tjänsten nativt (t.ex. 'sudo service postgresql start' / 'sudo service redis-server start' på WSL) eller installera Docker och kör om skriptet."
fi
say "Startar Postgres + Redis i Docker (idempotent) …"
docker compose -f infrastructure/docker/docker-compose.dev.yml up -d
}
export DB_URL="$(get_env DATABASE_URL)"
export R_URL="$(get_env REDIS_URL)"
export DATABASE_CA_CERT="$(get_env DATABASE_CA_CERT)"
EXTRA_CA=$(get_env NODE_EXTRA_CA_CERTS)
[ -n "$EXTRA_CA" ] && export NODE_EXTRA_CA_CERTS="$EXTRA_CA"
if DB_URL="$DB_URL" db_reachable; then
say "Databasen svarar redan ingen uppstart behövs."
elif [ "$MODE" = "staging" ]; then
start_docker_services "Databasen"
for i in $(seq 1 30); do
DB_URL="$DB_URL" db_reachable && break
sleep 1
[ "$i" = 30 ] && fail "Postgres svarade inte inom 30 s."
done
else
fail "DATABASE_URL svarar inte i produktion startar detta skript aldrig databaser. Kontrollera RDS/nätverk."
fi
if R_URL="$R_URL" redis_reachable; then
say "Redis svarar."
elif [ "$MODE" = "staging" ]; then
start_docker_services "Redis"
for i in $(seq 1 30); do
R_URL="$R_URL" redis_reachable && break
sleep 1
[ "$i" = 30 ] && fail "Redis svarade inte inom 30 s."
done
else
fail "REDIS_URL svarar inte i produktion startar detta skript aldrig tjänster. Kontrollera Redis/nätverk."
fi
say "GRIND 2 OK: databas + Redis nåbara"
# ---------------------------------------------------------------------------
# GRIND 3: Beroenden + verifiering av källkod
# ---------------------------------------------------------------------------
say "pnpm install (frozen lockfile) …"
pnpm install --frozen-lockfile >/dev/null
say "Varumärkesvakt …"
./scripts/brand-guard.sh
say "Typecheck + tester + build …"
pnpm typecheck >/dev/null
pnpm test >/dev/null
pnpm build >/dev/null
say "GRIND 3 OK: typecheck, tester, build, varumärkesvakt"
# ---------------------------------------------------------------------------
# GRIND 4: Migrationer (produktion: med backup före spec §63)
# ---------------------------------------------------------------------------
if [ "$MODE" = "production" ]; then
command -v pg_dump >/dev/null || fail "pg_dump saknas krävs för backup före migration."
mkdir -p "$ROOT/backups"
DBURL=$(get_env DATABASE_URL)
say "Backup före migration …"
pg_dump "$DBURL" --format=custom --file="$ROOT/backups/pre-first-deploy-$(date +%Y%m%d_%H%M%S).dump" \
|| fail "Backup misslyckades ingen migration utan backup (spec §63)."
fi
say "Kör migrationer …"
pnpm db:migrate
if [ "$MODE" = "staging" ]; then
say "Seedar databasen (endast staging) …"
pnpm db:seed
fi
say "GRIND 4 OK: databas migrerad$([ "$MODE" = staging ] && echo ' + seedad')"
# ---------------------------------------------------------------------------
# GRIND 5: AAMOS-utvärdering (spec §40)
# ---------------------------------------------------------------------------
say "Kör AAMOS-utvärderingssviten mot läge: $AAMOS_MODE_VAL"
if [ "$AAMOS_MODE_VAL" = "gemini" ]; then
# Skiva 1: endast kylskåps-/skafferiskanning är aktiv mot Gemini.
pnpm eval:scan || fail "Gemini scan-eval UNDERKÄND deploya inte (spec §40)."
else
pnpm eval:aamos || fail "Utvärderingssviten UNDERKÄND deploya inte (spec §40)."
fi
say "GRIND 5 OK: eval-sviten GODKÄND"
# ---------------------------------------------------------------------------
# GRIND 6: Starta tjänsterna + hälsokontroll
# ---------------------------------------------------------------------------
API_PORT=$(get_env API_PORT); API_PORT=${API_PORT:-4000}
say "Startar API + worker (bakgrund) …"
# Idempotent omstart: döda ev. tidigare instanser från detta skript.
pkill -f "apps/api/dist/index.js" 2>/dev/null || true
pkill -f "apps/worker/dist/index.js" 2>/dev/null || true
sleep 1
mkdir -p "$ROOT/logs"
nohup node apps/api/dist/index.js >> "$ROOT/logs/api.log" 2>&1 &
nohup node apps/worker/dist/index.js >> "$ROOT/logs/worker.log" 2>&1 &
for i in $(seq 1 20); do
curl -fsS "http://127.0.0.1:$API_PORT/healthz" >/dev/null 2>&1 && break
sleep 1
[ "$i" = 20 ] && { tail -20 "$ROOT/logs/api.log"; fail "API svarade inte på /healthz inom 20 s."; }
done
curl -fsS "http://127.0.0.1:$API_PORT/healthz"
echo
curl -fsS "http://127.0.0.1:$API_PORT/readyz" >/dev/null || fail "/readyz underkänd DB/Redis-koppling trasig."
grep -q "Worker igång" "$ROOT/logs/worker.log" || sleep 3
grep -q "Worker igång" "$ROOT/logs/worker.log" || fail "Workern startade inte (se logs/worker.log)."
say "GRIND 6 OK: API + worker uppe, healthz/readyz gröna"
# ---------------------------------------------------------------------------
# GRIND 7: Funktionsrök (registrera -> logga in -> läsa recept)
# ---------------------------------------------------------------------------
SMOKE_EMAIL="smoke-$(date +%s)@example.com"
B="http://127.0.0.1:$API_PORT"
TOKEN=$(curl -fsS -X POST "$B/v1/auth/register" -H 'content-type: application/json' \
-d "{\"email\":\"$SMOKE_EMAIL\",\"password\":\"FirstDeploy123!\",\"displayName\":\"Deploy Smoke\",\"locale\":\"sv-SE\"}" \
| node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).accessToken))")
[ -n "$TOKEN" ] || fail "Registrering i röktestet misslyckades."
RECIPES=$(curl -fsS "$B/v1/recipes?limit=1" -H "Authorization: Bearer $TOKEN" \
| node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>console.log(JSON.parse(d).recipes.length))")
if [ "$MODE" = "staging" ]; then
[ "$RECIPES" -ge 1 ] || fail "Röktestet hittade inga recept (seed saknas?)."
fi
say "GRIND 7 OK: register -> login -> recept fungerar"
# ---------------------------------------------------------------------------
# GRIND 8: Integrationsstatus vad är PÅ RIKTIGT och vad är mock?
# ---------------------------------------------------------------------------
EMAIL_MODE_VAL=$(get_env EMAIL_MODE); EMAIL_MODE_VAL=${EMAIL_MODE_VAL:-log}
S3_MODE_VAL=$(get_env S3_MODE); S3_MODE_VAL=${S3_MODE_VAL:-mock}
aamos_status=$([ "$AAMOS_MODE_VAL" = "http" ] && echo "RIKTIG (http)" || [ "$AAMOS_MODE_VAL" = "gemini" ] && echo "RIKTIG (Gemini)" || echo "mock")
email_status=$([ "$EMAIL_MODE_VAL" = "smtp" ] && echo "RIKTIG (smtp)" || echo "log (mejl till logs/api.log)")
s3_status=$([ "$S3_MODE_VAL" = "aws" ] && echo "RIKTIG (S3: $(get_env S3_BUCKET))" || echo "mock (lokal disk)")
say "-----------------------------------------------"
say "INTEGRATIONSSTATUS:"
say " AAMOS (AI): $aamos_status"
say " E-post: $email_status"
say " Fillagring: $s3_status"
say "-----------------------------------------------"
if [ "${REQUIRE_REAL:-0}" = "1" ]; then
# Skiva 1: Gemini-lärar-tier räknas som riktig AI. E-post/S3 får fortfarande
# vara mock i staging tills de konfigureras; AI får aldrig mocka tyst.
if [ "$AAMOS_MODE_VAL" = "gemini" ]; then
[ -n "$(get_env GEMINI_API_KEY)" ] || fail "REQUIRE_REAL=1 men GEMINI_API_KEY saknas för AAMOS_MODE=gemini."
say "GRIND 8 OK: REQUIRE_REAL uppfyllt Gemini-lärar-tier är riktig."
else
MOCKS=""
[ "$AAMOS_MODE_VAL" != "http" ] && MOCKS="$MOCKS AAMOS(AAMOS_MODE=http+URL+nyckel)"
[ "$EMAIL_MODE_VAL" != "smtp" ] && MOCKS="$MOCKS E-post(EMAIL_MODE=smtp+SMTP_*+MAIL_FROM)"
[ "$S3_MODE_VAL" != "aws" ] && MOCKS="$MOCKS Fillagring(S3_MODE=aws+S3_*)"
if [ -n "$MOCKS" ]; then
fail "REQUIRE_REAL=1 men följande kör fortfarande mock:$MOCKS fyll i .env och kör om."
fi
say "GRIND 8 OK: REQUIRE_REAL uppfyllt alla integrationer är riktiga."
fi
fi
# ---------------------------------------------------------------------------
# Klart
# ---------------------------------------------------------------------------
API_BASE_URL_VAL=$(get_env API_BASE_URL); API_BASE_URL_VAL=${API_BASE_URL_VAL:-http://127.0.0.1:$API_PORT}
say "==============================================="
say "ALLA GRINDAR GRÖNA $MODE är uppe."
say "API: http://127.0.0.1:$API_PORT (Swagger: /docs)"
say "API_BASE_URL (för mobil/presign): $API_BASE_URL_VAL"
say "Loggar: $ROOT/logs/api.log, $ROOT/logs/worker.log"
if [ "$MODE" = "staging" ]; then
say "Admin: bygg finns i apps/admin/dist servera med valfri statisk server."
say "OBS: staging kör AAMOS_MODE=$AAMOS_MODE_VAL."
say "NÄSTA (LAN-telefontest): ./scripts/expo-lan.sh (aldrig pnpm start direkt)"
else
say "NÄSTA: peka reverse proxy (TLS) mot 127.0.0.1:$API_PORT och verifiera utifrån."
fi
say "==============================================="