dev #228
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal file
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal 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;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
133
backend/tests/instanceAgentMigration.test.ts
Normal file
133
backend/tests/instanceAgentMigration.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user