fix(scan): restore missing scanner database migrations

This commit is contained in:
2026-09-23 18:21:37 +02:00
parent 7164989da9
commit 96295ee4f0
4 changed files with 204 additions and 0 deletions

View File

@@ -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-<ID>.service -f`. Eine erneute Installation ersetzt nur die Dateien dieses Agenten. 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-<ID>.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 ## Voraussetzungen
### macOS ### macOS

View File

@@ -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;

View File

@@ -484,6 +484,13 @@
"when": 1790121600000, "when": 1790121600000,
"tag": "0071_statement_allocation_costcentre", "tag": "0071_statement_allocation_costcentre",
"breakpoints": true "breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1790121600001,
"tag": "0072_restore_instance_scan_agents",
"breakpoints": true
} }
] ]
} }

View File

@@ -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()
}
})
}
})