diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..4ce5064 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,50 @@ +# Gitea Actions Deploy für das Cockpit. +# Läuft NUR bei Push auf main und deployt auf dem Homeserver, auf dem auch +# Gitea und der act_runner laufen. Der Job läuft im Host-Executor (runs-on: host), +# damit er Zugriff auf den Docker-Daemon und die Host-Pfade hat: +# /opt/build/cockpit → Source-Checkout (Build-Context, gehört dem Runner-User) +# /opt/stacks/cockpit → docker-compose.yml + .env (root-owned → via sudo) + +name: deploy + +on: + push: + branches: [main] + +concurrency: + group: deploy-cockpit + cancel-in-progress: false + +jobs: + deploy: + runs-on: host + steps: + - name: Source-Checkout auf origin/main aktualisieren + run: | + set -euo pipefail + git -C /opt/build/cockpit fetch --prune origin + git -C /opt/build/cockpit reset --hard origin/main + echo "Deployed commit:" + git -C /opt/build/cockpit log --oneline -1 + + - name: Container bauen & neu starten + run: | + set -euo pipefail + cd /opt/stacks/cockpit + sudo docker compose up -d --build --remove-orphans + + - name: Auf API-Health warten + run: | + set -euo pipefail + for i in $(seq 1 36); do + status=$(sudo docker inspect -f '{{.State.Health.Status}}' cockpit-api 2>/dev/null || echo "missing") + echo " api health: $status" + if [ "$status" = "healthy" ]; then + echo "✅ cockpit-api ist healthy." + exit 0 + fi + sleep 5 + done + echo "❌ cockpit-api wurde nicht rechtzeitig healthy." >&2 + sudo docker logs --tail 80 cockpit-api || true + exit 1 diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 339983d..d4cfead 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -15,7 +15,14 @@ COPY apps/api ./apps/api COPY packages/db ./packages/db COPY packages/shared ./packages/shared -RUN pnpm --filter api build +# Die Workspace-Libs werden zur Laufzeit (Node) als JS geladen. Ihre tsconfig hat +# jedoch noEmit:true und main zeigt auf die TS-Quelle — Node kann das nicht laden. +# Daher hier mit Emit kompilieren und main/exports im Image auf die dist-Artefakte +# umbiegen (nur im Image; Repo-Quelle, Dev-Modus und Web-/Vite-Build bleiben unberührt). +RUN pnpm exec tsc -p packages/shared/tsconfig.json --noEmit false \ + && pnpm exec tsc -p packages/db/tsconfig.json --noEmit false \ + && pnpm --filter api build \ + && node -e "const fs=require('fs');for(const p of ['packages/db','packages/shared']){const f=p+'/package.json';const j=JSON.parse(fs.readFileSync(f,'utf8'));const fix=s=>s.replace('./src/','./dist/').replace(/[.]ts$/,'.js');if(j.main){j.main=fix(j.main);j.types=j.main.replace(/[.]js$/,'.d.ts');}if(j.exports)for(const k in j.exports){if(typeof j.exports[k]==='string')j.exports[k]=fix(j.exports[k]);}fs.writeFileSync(f,JSON.stringify(j,null,2));}" # --- runtime --- FROM node:22-alpine AS runtime diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 5edfb95..dff0a78 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,6 +1,7 @@ import Fastify from 'fastify'; import cookie from '@fastify/cookie'; import { fastifyTRPCPlugin, type FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify'; +import { runMigrations } from '@cockpit/db'; import { loadEnv } from './env.js'; import { logger } from './lib/logger.js'; import { registerAuthMiddleware } from './auth/middleware.js'; @@ -12,6 +13,17 @@ import { createContext } from './trpc.js'; async function main() { const env = loadEnv(); + + // Schema-Migrationen vor dem Start anwenden (idempotent). Schlägt eine + // Migration fehl, startet der Server nicht — besser als mit veraltetem Schema. + try { + await runMigrations(env.DATABASE_URL); + logger.info('db migrations applied'); + } catch (err) { + logger.error({ err }, 'db migration failed'); + process.exit(1); + } + const app = Fastify({ logger: { level: env.LOG_LEVEL, diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index df0523e..b03c6f8 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,5 +1,6 @@ export * from './schema.js'; export { createDbClient } from './client.js'; +export { runMigrations } from './migrate.js'; export type { Db, CreateDbClientOptions } from './client.js'; export const DB_VERSION = '0.0.1'; diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts new file mode 100644 index 0000000..e11570d --- /dev/null +++ b/packages/db/src/migrate.ts @@ -0,0 +1,31 @@ +import { drizzle } from 'drizzle-orm/node-postgres'; +import { migrate } from 'drizzle-orm/node-postgres/migrator'; +import pg from 'pg'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +/** + * Wendet alle ausstehenden Drizzle-Migrationen aus `packages/db/migrations` an. + * Idempotent: Drizzle führt Buch über bereits eingespielte Migrationen in + * `__drizzle_migrations` und überspringt sie. Wird beim API-Start aufgerufen, + * damit ein Deploy automatisch das Schema aktualisiert. + * + * Der Migrations-Ordner liegt eine Ebene über dieser Datei — funktioniert + * sowohl für die kompilierte Variante (`dist/migrate.js` → `../migrations`) + * als auch im Dev-Modus (`src/migrate.ts` → `../migrations`). + */ +export async function runMigrations(connectionString: string): Promise { + const migrationsFolder = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'migrations', + ); + + const pool = new pg.Pool({ connectionString, max: 1 }); + try { + const db = drizzle(pool); + await migrate(db, { migrationsFolder }); + } finally { + await pool.end(); + } +}