diff --git a/agents/fedeo-device-agent/README.md b/agents/fedeo-device-agent/README.md index b65df957..94a03673 100644 --- a/agents/fedeo-device-agent/README.md +++ b/agents/fedeo-device-agent/README.md @@ -26,6 +26,19 @@ Die Installation ist je Agent getrennt: Die entsprechenden XDG-Verzeichnisse werden berücksichtigt. Der Autostart beginnt bei der Benutzeranmeldung. Ohne Autostart muss das gestartete Terminal geöffnet bleiben. Protokolle: `journalctl --user -u fedeo-device-agent-.service -f`. Eine erneute Installation ersetzt nur die Dateien dieses Agenten. +### Scannerverwaltung meldet HTTP 500 nach einem Serverupdate + +Die älteren Scanner-SQL-Dateien `0051` bis `0053` waren nicht im Drizzle-Migrationsjournal eingetragen und wurden dadurch bei regulären Updates übersprungen. Die Vorwärtsmigration `0072_restore_instance_scan_agents` legt fehlende Tabellen und Spalten nachträglich an. Vorhandene Agenten, Tokens, Einstellungen und Scanaufträge bleiben erhalten. + +Zuerst einen Backend-Stand mit dieser Migration ausrollen. Bei aktiviertem `FEDEO_RUN_MIGRATIONS` wird sie beim Start automatisch ausgeführt. Bei bewusst deaktivierten Startmigrationen muss anschließend im aktualisierten Backend `npm run migrate` ausgeführt werden. Die alten Journal-Zeitstempel nachträglich einzufügen genügt bei bereits aktualisierten Datenbanken nicht. + +Der Datenbanktest läuft isoliert gegen ein temporäres PostgreSQL-Image und verwendet keine Anwendungskonfiguration: + +```bash +cd backend +FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine node --import tsx tests/instanceAgentMigration.test.ts +``` + ## Voraussetzungen ### macOS diff --git a/backend/db/migrations/0072_restore_instance_scan_agents.sql b/backend/db/migrations/0072_restore_instance_scan_agents.sql new file mode 100644 index 00000000..d3ba67fa --- /dev/null +++ b/backend/db/migrations/0072_restore_instance_scan_agents.sql @@ -0,0 +1,51 @@ +-- Forward repair: 0051-0053 were shipped without migration journal entries. +-- Keep existing agents/jobs when these SQL files were applied manually. +CREATE TABLE IF NOT EXISTS "instance_agents" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "name" text NOT NULL, + "description" text, + "token_prefix" text NOT NULL, + "token_hash" text NOT NULL, + "active" boolean DEFAULT true NOT NULL, + "capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL, + "scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL, + "printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL, + "last_seen_at" timestamp with time zone, + "last_debug_info" jsonb, + CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash") +); +--> statement-breakpoint +CREATE TABLE IF NOT EXISTS "instance_agent_scan_jobs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + "tenant_id" bigint NOT NULL, + "agent_id" uuid NOT NULL, + "requested_by" uuid, + "status" text DEFAULT 'pending' NOT NULL, + "scanner_name" text, + "requested_filename" text, + "settings" jsonb DEFAULT '{}'::jsonb NOT NULL, + "target" jsonb DEFAULT '{}'::jsonb NOT NULL, + "agent_message" text, + "attempts" integer DEFAULT 0 NOT NULL, + "claimed_at" timestamp with time zone, + "finished_at" timestamp with time zone, + "file_id" uuid, + CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade, + CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade, + CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade, + CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade +); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at"); +--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at"); +--> statement-breakpoint +ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "preferred_scanner_name" text; +--> statement-breakpoint +ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "scan_defaults" jsonb DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null}'::jsonb NOT NULL; +--> statement-breakpoint +ALTER TABLE "instance_agents" ALTER COLUMN "scan_defaults" SET DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null,"postprocess":false,"postprocessProfile":"document"}'::jsonb; diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index ab6d3bcd..3e3ce195 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -484,6 +484,13 @@ "when": 1790121600000, "tag": "0071_statement_allocation_costcentre", "breakpoints": true + }, + { + "idx": 69, + "version": "7", + "when": 1790121600001, + "tag": "0072_restore_instance_scan_agents", + "breakpoints": true } ] } diff --git a/backend/tests/instanceAgentMigration.test.ts b/backend/tests/instanceAgentMigration.test.ts new file mode 100644 index 00000000..d7815cba --- /dev/null +++ b/backend/tests/instanceAgentMigration.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { execFile } from "node:child_process" +import { promisify } from "node:util" +import { readFile, mkdir, mkdtemp, writeFile, rm } from "node:fs/promises" +import { readFileSync } from "node:fs" +import path from "node:path" +import os from "node:os" +import { randomUUID } from "node:crypto" +import { Pool } from "pg" +import { drizzle } from "drizzle-orm/node-postgres" +import { migrate } from "drizzle-orm/node-postgres/migrator" +import { readMigrationFiles } from "drizzle-orm/migrator" +import Fastify from "fastify" +import routes from "../src/routes/instanceAgents" + +const migrationsFolder = path.resolve(__dirname, "../db/migrations") + path.sep +const repairTag = "0072_restore_instance_scan_agents" +const journal = JSON.parse(readFileSync(migrationsFolder + "meta/_journal.json", "utf8")).entries +const repair = journal.find((entry: any) => entry.tag === repairTag) +const previousWatermark = Math.max(...journal.filter((entry: any) => entry.idx < repair?.idx).map((entry: any) => entry.when)) +const execute = promisify(execFile) + +test("the scanner repair is registered after every previously shipped migration", () => { + assert.ok(repair, "scanner repair must be in the Drizzle journal") + assert.ok(repair.when > previousWatermark) + const pending = readMigrationFiles({ migrationsFolder }).filter(entry => entry.folderMillis > previousWatermark) + assert.ok(pending.some(entry => entry.sql.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS "instance_agents"')))) +}) + +// Opt in with FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine. This creates its own +// disposable database container; it never reads DATABASE_URL or application secrets. +test("migration repairs upgraded databases and preserves manually installed scanners", { + skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE, +}, async t => { + // Isolate this repair from unrelated future migrations. Registration in the + // actual shipped journal is checked above; its timestamp remains unchanged. + const testMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-migrations-")) + t.after(() => rm(testMigrations, { recursive: true, force: true })) + await mkdir(path.join(testMigrations, "meta")) + await writeFile(path.join(testMigrations, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [repair] })) + await writeFile(path.join(testMigrations, repairTag + ".sql"), await readFile(migrationsFolder + repairTag + ".sql")) + const container = "fedeo-scan-migration-" + randomUUID() + const password = randomUUID() + await execute("docker", ["run", "--rm", "-d", "--name", container, + "-e", "POSTGRES_PASSWORD=" + password, "-p", "127.0.0.1::5432", + process.env.FEDEO_TEST_POSTGRES_IMAGE!]) + let admin: Pool | undefined + t.after(async () => { + try { await admin?.end() } + finally { await execute("docker", ["rm", "-f", container]) } + }) + const { stdout } = await execute("docker", ["port", container, "5432/tcp"]) + const port = Number(stdout.trim().split(":").at(-1)) + admin = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 }) + for (let attempt = 0; ; attempt++) { + try { await admin.query("SELECT 1"); break } catch (error) { + if (attempt >= 80) throw error + await new Promise(resolve => setTimeout(resolve, 100)) + } + } + + for (const state of ["absent", "0051", "0052", "0053"]) { + await t.test(state, async () => { + const database = "scan_" + state + await admin.query('CREATE DATABASE "' + database + '"') + const pool = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database, max: 1 }) + const app = Fastify() + try { + await pool.query(` + CREATE TABLE tenants (id bigint PRIMARY KEY); + CREATE TABLE auth_users (id uuid PRIMARY KEY); + CREATE TABLE files (id uuid PRIMARY KEY); + INSERT INTO tenants VALUES (1); + CREATE SCHEMA drizzle; + CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint); + `) + // An existing installation already passed 0071. Re-inserting old + // journal entries 0051-0053 would therefore NOT fix this database. + await pool.query("INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('previous-release', $1)", [previousWatermark]) + if (state !== "absent") { + for (const file of ["0051_instance_scan_agents", "0052_instance_agent_scan_defaults", "0053_instance_agent_postprocess_defaults"]) { + if (file.slice(0, 4) <= state) await pool.query(await readFile(migrationsFolder + file + ".sql", "utf8")) + } + await pool.query(` + INSERT INTO instance_agents (id, name, token_prefix, token_hash) + VALUES ('00000000-0000-4000-8000-000000000001', 'Existing scanner', 'prefix', 'existing-hash'); + INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id, status, settings) + VALUES (1, '00000000-0000-4000-8000-000000000001', 'completed', '{"resolution":600}'); + `) + if (state !== "0051") { + await pool.query(`UPDATE instance_agents SET preferred_scanner_name='existing-device', scan_defaults='{"resolution":600}'`) + } + } + + const db = drizzle(pool) + await migrate(db, { migrationsFolder: testMigrations }) + await migrate(db, { migrationsFolder: testMigrations }) + // Also prove the repair SQL itself is safe after manual application. + await pool.query(await readFile(migrationsFolder + repairTag + ".sql", "utf8")) + app.decorate("db", db) + app.addHook("onRequest", async req => { req.user = { is_admin: true } as any }) + await app.register(routes) + const listing = await app.inject({ method: "GET", url: "/instance-agents" }) + assert.equal(listing.statusCode, 200, listing.body) + const agents = listing.json().agents + assert.equal(agents.length, state === "absent" ? 0 : 1) + if (state !== "absent") { + assert.equal(agents[0].name, "Existing scanner") + assert.equal((await pool.query("SELECT token_hash FROM instance_agents")).rows[0].token_hash, "existing-hash") + assert.equal((await pool.query("SELECT status, settings FROM instance_agent_scan_jobs")).rows[0].settings.resolution, 600) + if (state !== "0051") { + assert.equal(agents[0].preferredScannerName, "existing-device") + assert.deepEqual(agents[0].scanDefaults, { resolution: 600 }) + } + } + const created = await app.inject({ method: "POST", url: "/instance-agents", payload: { name: "New scanner" } }) + assert.equal(created.statusCode, 200, created.body) + assert.equal(created.json().agent.scanDefaults.postprocess, false) + assert.equal(created.json().agent.scanDefaults.postprocessProfile, "document") + assert.equal(created.json().agent.scanDefaults.format, "pdf") + assert.equal((await pool.query("SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE created_at = $1", [repair.when])).rows[0].count, 1) + const indexes = (await pool.query("SELECT indexname FROM pg_indexes WHERE tablename='instance_agent_scan_jobs'")).rows.map(row => row.indexname) + assert.ok(indexes.includes("instance_agent_scan_jobs_agent_status_idx")) + assert.ok(indexes.includes("instance_agent_scan_jobs_tenant_idx")) + await assert.rejects(pool.query("INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id) VALUES (999, $1)", [created.json().agent.id]), { code: "23503" }) + } finally { + await app.close() + await pool.end() + } + }) + } +})