6e2d794130
- @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
332 lines
16 KiB
Bash
Executable File
332 lines
16 KiB
Bash
Executable File
#!/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 "==============================================="
|