12 Commits

Author SHA1 Message Date
20091143d2 Merge pull request 'dev' (#228) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 6m5s
Reviewed-on: #228
2026-09-23 17:20:58 +00:00
8e1505023b feat(selfhost): start full updates from administration
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 5m49s
2026-09-23 18:49:10 +02:00
60553bbba8 fix(auth): defer dashboard loading until tenant selection 2026-09-23 18:21:40 +02:00
96295ee4f0 fix(scan): restore missing scanner database migrations 2026-09-23 18:21:37 +02:00
f708d0a2b2 Merge pull request 'dev' (#227) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 44s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #227
2026-09-23 16:15:44 +00:00
7164989da9 feat(selfhost): add persistent host update service
All checks were successful
Build and Release FEDEO / verify (push) Successful in 47s
Build and Release FEDEO / build-and-release (push) Successful in 5m53s
2026-09-23 18:09:04 +02:00
cbec5b058d feat(banking): add cost centres and VAT to account allocations 2026-09-23 18:08:09 +02:00
847a616285 fix(selfhost): retire deprecated Matrix services during updates 2026-09-23 17:43:12 +02:00
fdf1d42a3d fix(selfhost): explain unsupported update services 2026-09-23 17:36:30 +02:00
7e9f4409f6 Merge pull request 'dev' (#226) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 33s
Build and Release FEDEO / build-and-release (push) Successful in 6m10s
Reviewed-on: #226
2026-09-23 14:57:40 +00:00
0ff74cdcdf feat(selfhost): add verified release updates with backups and recovery state
Some checks failed
Build and Release FEDEO / verify (push) Successful in 59s
Build and Release FEDEO / build-and-release (push) Failing after 4m59s
2026-09-23 16:52:00 +02:00
f51f2b13a1 fix(selfhost): pin MinIO images and add application health checks 2026-09-23 16:39:17 +02:00
57 changed files with 4556 additions and 703 deletions

View File

@@ -17,7 +17,10 @@ jobs:
with:
node-version: '22'
- run: npm ci --ignore-scripts --no-audit --no-fund
- name: Test release planning and recovery
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Test releases and selfhost updates
run: npm test
- name: Check Conventional Commits
run: npm run commits:check

1
.gitignore vendored
View File

@@ -3,6 +3,7 @@ node_modules/
.nuxt/
.output/
/.release/
__pycache__/
# Lokale Runtime-Daten und generierte Konfigurationen
matrix/postgres/

View File

@@ -487,49 +487,27 @@ Wenn der Bootstrap aktiviert ist, kannst du dich danach mit `FEDEO_BOOTSTRAP_ADM
## Updates
Script-installierte Instanzen liegen standardmassig unter `/opt/fedeo`. Ein Update besteht daraus, die Selfhost-Dateien zu erneuern, die vorgebauten Images zu ziehen und den Compose-Stack neu zu starten.
Vor kritischen Updates sollte ein Backup der persistenten Daten erstellt werden:
Vollständige Release-Updates einschließlich Compose-Datei werden mit dem Host-Skript vorbereitet und ausgeführt:
```bash
cd /opt/fedeo
tar -czf "backup-$(date +%Y%m%d-%H%M%S).tar.gz" .env postgres minio
sudo python3 /opt/fedeo/scripts/selfhost-update.py check
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.1.0
sudo python3 /opt/fedeo/scripts/selfhost-update.py status
```
Danach die aktuellen Selfhost-Dateien laden und den Stack aktualisieren:
`3.1.0` durch den gewünschten veröffentlichten Release ersetzen. Das Skript benötigt Python ab 3.10 und Docker Compose ab 2.24. Es sichert die lokale Datenbank, MinIO, Konfiguration und Zertifikate vor dem Austausch, erhält `.env` und prüft die gestarteten Versionen. Während Backup und Aktualisierung gibt es eine Wartungsunterbrechung.
```bash
cd /opt/fedeo
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/docker-compose.selfhost.yml -o /opt/fedeo/docker-compose.yml
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/.env.example -o /opt/fedeo/.env.example
curl -fsSL https://git.federspiel.tech/flfeders/FEDEO/raw/branch/dev/scripts/selfhost-setup.sh -o /opt/fedeo/scripts/selfhost-setup.sh
chmod +x /opt/fedeo/scripts/selfhost-setup.sh
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml pull
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
```
Beim ersten Update die erzeugte Compose-Differenz prüfen, eigene Anpassungen in eine Override-Datei übertragen und den Austausch mit `--accept-compose-replacement` bestätigen. Bestehende Installationen müssen das Update-Skript zunächst aus einem passenden Release übernehmen.
Der Backend-Container wendet Datenbankmigrationen beim Start automatisch an, solange `FEDEO_RUN_MIGRATIONS` nicht deaktiviert wurde. Falls Migrationen manuell erzwungen werden sollen:
Unter **Administration → Systemstatus → FEDEO-Updates** können Systemadministratoren neue Releases prüfen und nach Bestätigung installieren. Dazu führt `fedeo-updater.service` die Aufträge unabhängig von Browser und Backend-Neustarts aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die aktuelle Selfhost-Compose-Datei bindet dessen lokales Socket-Verzeichnis im Backend ein. Bestehende Installationen benötigen einmalig die aktuellen Hostskripte und ein CLI-Update auf einen Release mit der Oberfläche. Einrichtung und Schritte stehen in der folgenden Anleitung.
```bash
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml run --rm backend npm run migrate
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml up -d
```
Anschliessend den Zustand prüfen:
```bash
docker compose --env-file /opt/fedeo/.env -f /opt/fedeo/docker-compose.yml ps
curl -I https://app.example.com
curl https://app.example.com/backend/health
```
Die Selfhost-Compose-Datei nutzt vorgebaute Images. Dadurch braucht der Server keinen Repository-Checkout und keine lokalen Build-Kontexte.
Voraussetzungen, unterstützte Konfigurationen und Wiederherstellung stehen in [Vollständige Selfhost-Updates](docs/selfhost-updates.md). Für ältere Releases steht der manuelle Ablauf in [Versionierung und Releases](docs/releases.md).
## Backup-Empfehlung
Regelmassig sichern:
- `./postgres`
- PostgreSQL mit einem konsistenten Datenbankbackup, beispielsweise `pg_dump`
- `./minio` falls MinIO lokal genutzt wird
- `./traefik/letsencrypt/acme.json`
- deine `.env`

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.
### 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

View File

@@ -0,0 +1,2 @@
ALTER TABLE "statementallocations"
ADD COLUMN "costcentre" uuid REFERENCES "costcentres"("id");

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

@@ -477,6 +477,20 @@
"when": 1788850800000,
"tag": "0070_native_communication_chat",
"breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1790121600000,
"tag": "0071_statement_allocation_costcentre",
"breakpoints": true
},
{
"idx": 69,
"version": "7",
"when": 1790121600001,
"tag": "0072_restore_instance_scan_agents",
"breakpoints": true
}
]
}

View File

@@ -18,6 +18,7 @@ import { incominginvoices } from "./incominginvoices"
import { createddocuments } from "./createddocuments"
import { bankstatements } from "./bankstatements"
import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden!
import { costcentres } from "./costcentres"
export const statementallocations = pgTable("statementallocations", {
id: uuid("id").primaryKey().defaultRandom(),
@@ -58,6 +59,7 @@ export const statementallocations = pgTable("statementallocations", {
manualBookingDate: text("manual_booking_date"),
datevTaxKey: text("datev_tax_key"),
costcentre: uuid("costcentre").references(() => costcentres.id),
bookingMode: text("booking_mode").notNull().default("expense"),
depreciationMonths: integer("depreciation_months"),

View File

@@ -9,6 +9,7 @@ import authRoutes from "./routes/auth/auth";
import authRoutesAuthenticated from "./routes/auth/auth-authenticated";
import authPlugin from "./plugins/auth";
import adminRoutes from "./routes/admin";
import selfhostUpdateRoutes from "./routes/selfhostUpdates";
import corsPlugin from "./plugins/cors";
import queryConfigPlugin from "./plugins/queryconfig";
import dbPlugin from "./plugins/db";
@@ -98,10 +99,6 @@ async function main() {
done()
})
app.get('/health', async (req, res) => {
return res.send({ status: 'ok' })
})
//Plugin nur auf bestimmten Routes
await app.register(queryConfigPlugin, {
routes: ['/api/resource/:resource/paginated']
@@ -147,6 +144,7 @@ async function main() {
await subApp.register(meRoutes);
await subApp.register(tenantRoutes);
await subApp.register(adminRoutes);
await subApp.register(selfhostUpdateRoutes);
await subApp.register(resourceRoutesSpecial);
await subApp.register(historyRoutes);
await subApp.register(fileRoutes);

View File

@@ -1,3 +1,5 @@
import { normalizeBankAllocationDetails } from "../modules/bank-allocation-details"
const hasValue = (value: unknown) => value !== null && value !== undefined && value !== ""
const stringArg = (args: Record<string, unknown>, key: string) => {
@@ -110,6 +112,10 @@ export const prepareStatementAllocationInput = (args: Record<string, unknown>) =
if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide
}
const details = normalizeBankAllocationDetails({ ...payload, costcentre: args.costcentre })
if (args.costcentre !== undefined) payload.costcentre = details.costcentre
if (args.datevTaxKey !== undefined) payload.datevTaxKey = details.datevTaxKey
if (bookingMode === "expense") return payload
const depreciationMonths = requiredNumber(args, "depreciationMonths")

View File

@@ -2,6 +2,7 @@ import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, or } from "drizzle-orm"
import { randomUUID } from "node:crypto"
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
import {
accounts,
bankstatements,
@@ -406,6 +407,7 @@ const assertTenantEntityExists = async (
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
await Promise.all([
validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre),
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"),
@@ -1419,7 +1421,8 @@ export const accountingTools: McpTool[] = [
manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." },
manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." },
description: { type: "string" },
datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." },
datevTaxKey: { type: "string", description: "DATEV-Steuerschlüssel auf den Bruttobetrag: 9/8 für 19/7 % Vorsteuer, 3/2 für 19/7 % Umsatzsteuer, 40 ohne Steuer (Automatik aufheben)." },
costcentre: { type: ["string", "null"], description: "Kostenstellen-UUID für direkte Bankbuchungen auf Sachkonten oder zusätzliche Konten." },
bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" },
depreciationMonths: { type: "number", minimum: 1 },
depreciationStartDate: { type: "string", format: "date" },

View File

@@ -0,0 +1,30 @@
import { and, eq } from "drizzle-orm"
import { costcentres } from "../../db/schema"
const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 })
export function normalizeBankAllocationDetails(payload: Record<string, any>) {
const costcentre = payload.costcentre ? String(payload.costcentre).trim() : null
const datevTaxKey = payload.datevTaxKey ? String(payload.datevTaxKey).trim() : null
if (costcentre && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(costcentre)) {
throw invalidInput("Die Kostenstelle muss eine gültige UUID sein.")
}
if (datevTaxKey && !/^\d{1,4}$/.test(datevTaxKey)) {
throw invalidInput("Der DATEV-Steuerschlüssel muss aus 1 bis 4 Ziffern bestehen.")
}
if (costcentre && (!payload.bankstatement || !(payload.account || payload.ownaccount)
|| payload.createddocument || payload.incominginvoice || payload.customer || payload.vendor)) {
throw invalidInput("Kostenstellen sind hier nur für direkte Bankbuchungen auf Buchungskonten zulässig.")
}
return { costcentre, datevTaxKey }
}
export async function validateBankAllocationCostCentre(db: any, tenantId: number, costcentre: string | null) {
if (!costcentre) return
const [record] = await db.select({ id: costcentres.id }).from(costcentres).where(and(
eq(costcentres.id, costcentre),
eq(costcentres.tenant, tenantId),
eq(costcentres.archived, false),
)).limit(1)
if (!record) throw invalidInput("Die Kostenstelle wurde nicht gefunden oder ist archiviert.")
}

View File

@@ -0,0 +1,91 @@
import { request } from "node:http";
import { z } from "zod";
const version = z.string().regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
export const requestId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/);
export const checkBody = z.object({ requestId, version: version.optional() }).strict();
export const applyBody = z.object({ requestId, version, checkRequestId: requestId }).strict();
const errors = {
INVALID_REQUEST: "Ungültige Anfrage.", NOT_FOUND: "Auftrag nicht gefunden.",
BUSY: "Eine Prüfung oder ein Update läuft bereits.", SHUTTING_DOWN: "Der Update-Dienst wird beendet.",
REQUEST_CONFLICT: "Diese Auftrags-ID wurde bereits für eine andere Anfrage verwendet.",
CHECK_REQUIRED: "Bitte die Zielversion erneut prüfen.",
COMPOSE_REVIEW_REQUIRED: "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
RECOVERY_REQUIRED: "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
PLAN_CHANGED: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen.",
UPDATE_FAILED: "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
INTERRUPTED: "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
UPDATER_UNAVAILABLE: "Der Host-Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung prüfen.",
UPDATER_PROTOCOL: "Der Host-Update-Dienst hat eine ungültige Antwort geliefert.",
};
type ErrorCode = keyof typeof errors;
const knownError = (code: unknown): code is ErrorCode => typeof code === "string" && Object.prototype.hasOwnProperty.call(errors, code);
export class UpdaterError extends Error {
constructor(public code: ErrorCode, public status = 503) { super(errors[code]); }
}
const state = z.object({
status: z.enum(["idle", "queued", "running", "checked", "up_to_date", "succeeded", "failed", "interrupted", "recovered"]),
phase: z.enum(["preparing", "validating", "checked", "pulling", "maintenance", "backed_up", "applying",
"retiring_matrix", "migrating", "starting", "verifying", "complete", "resolved"]).optional(),
command: z.enum(["check", "apply"]).optional(), requestId: requestId.optional(),
version: version.optional(), previousVersion: version.optional(), revision: z.string().regex(/^[a-f0-9]{40}$/).optional(),
recoveryRequired: z.boolean().optional(), maintenance: z.boolean().optional(), mutationStarted: z.boolean().optional(),
startedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(), updatedAt: z.string().regex(/^[0-9T:+.Z-]{10,40}$/).optional(),
});
const plan = state.omit({ status: true }).extend({
updateAvailable: z.boolean(), requiresComposeAdoption: z.boolean(),
estimatedBackupBytes: z.number().nonnegative().finite().optional(),
retiredServices: z.array(z.enum(["matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"])),
});
const job = state.extend({ plan: plan.optional(), progress: state.optional(),
error: z.object({ code: z.string() }).transform(({ code }) => ({
code: knownError(code) ? code : "UPDATE_FAILED",
message: knownError(code) ? errors[code] : errors.UPDATE_FAILED,
})).optional(),
});
const status = z.object({ apiVersion: z.literal(1), acceptingRequests: z.boolean(), engine: state,
installed: state.pick({ version: true, revision: true }), request: job.optional(),
});
// No caller-controlled host, URL, socket, headers or command can reach the privileged service.
export function createUpdaterClient(socketPath = "/run/fedeo-updater/control.sock", timeoutMs = 5000) {
return (method: "GET" | "POST", path: string, body?: unknown): Promise<{ status: number; body: unknown }> => {
if (!((method === "GET" && (path === "/v1/status" || /^\/v1\/requests\/[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(path)))
|| (method === "POST" && ["/v1/check", "/v1/apply"].includes(path)))) {
return Promise.reject(new UpdaterError("INVALID_REQUEST", 400));
}
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const req = request({ socketPath, path, method, agent: false, headers: payload ? {
"Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload),
} : {} }, res => {
const chunks: Buffer[] = [];
let bytes = 0;
res.on("data", chunk => {
bytes += chunk.length;
if (bytes > 65536) req.destroy(new UpdaterError("UPDATER_PROTOCOL", 502));
else chunks.push(chunk);
});
res.on("error", () => reject(new UpdaterError("UPDATER_UNAVAILABLE")));
res.on("end", () => {
try {
const data = JSON.parse(Buffer.concat(chunks).toString("utf8"));
const code = data?.error?.code;
if (![200, 202].includes(res.statusCode || 0)) {
throw knownError(code) && [400, 404, 409, 503].includes(res.statusCode!)
? new UpdaterError(code, res.statusCode) : new UpdaterError("UPDATER_PROTOCOL", 502);
}
resolve({ status: res.statusCode!, body: (path === "/v1/status" ? status : job).parse(data) });
} catch (error) {
reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_PROTOCOL", 502));
}
});
});
const timer = setTimeout(() => req.destroy(new UpdaterError("UPDATER_UNAVAILABLE")), timeoutMs);
req.on("close", () => clearTimeout(timer));
req.on("error", error => reject(error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE")));
req.end(payload);
});
};
}

View File

@@ -29,6 +29,7 @@ import {
importTenantFullExportArchive,
} from "../utils/tenantFullExport";
import type { TenantFullExport } from "../utils/tenantFullExport";
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
import { buildSystemStatus } from "../modules/system-status.service";
import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets";
@@ -563,29 +564,7 @@ export default async function adminRoutes(server: FastifyInstance) {
return job;
};
const requireAdmin = async (req: FastifyRequest, reply: FastifyReply) => {
if (!req.user?.user_id) {
reply.code(401).send({ error: "Unauthorized" });
return null;
}
const [currentUser] = await server.db
.select({
id: authUsers.id,
email: authUsers.email,
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.id, req.user.user_id))
.limit(1);
if (!currentUser?.is_admin) {
reply.code(403).send({ error: "Admin access required" });
return null;
}
return currentUser;
};
const requireAdmin = (req: FastifyRequest, reply: FastifyReply) => requireSystemAdmin(server, req, reply);
const ensurePortalRoleForTenant = async (tenantId: number, createdBy: string) => {
const existingRoles = await server.db

View File

@@ -9,6 +9,7 @@ import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes"
import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import { centralServicesClient } from "../modules/push-server.client"
import { findBankInstitutionByBic } from "../modules/banking-institution"
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
import {
bankrequisitions,
@@ -1166,6 +1167,8 @@ export default async function bankingRoutes(server: FastifyInstance) {
const { data: payload } = req.body as { data: any }
const prepared = prepareStatementAllocationPayload(payload)
if (prepared.error) return reply.code(400).send({ error: prepared.error })
Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data))
await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre)
prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data)
const inserted = await server.db.insert(statementallocations).values({
@@ -1217,6 +1220,7 @@ export default async function bankingRoutes(server: FastifyInstance) {
return reply.send(createdRecord)
} catch (err) {
if (err.statusCode === 400) return reply.code(400).send({ error: err.message })
console.error(err)
return reply.code(500).send({ error: "Failed to create statement" })
}

View File

@@ -2,6 +2,16 @@ import { FastifyInstance } from "fastify";
import { getBuildInfo } from "../utils/buildInfo";
export default async function routes(server: FastifyInstance) {
server.get("/health", async (_request, reply) => {
reply.header("Cache-Control", "no-store");
try {
await server.db.execute("SELECT 1");
return { status: "ok", ...getBuildInfo() };
} catch {
return reply.code(503).send({ status: "unavailable" });
}
});
server.get("/version", async (_request, reply) => {
reply.header("Cache-Control", "no-store");
return getBuildInfo();

View File

@@ -0,0 +1,38 @@
import type { FastifyInstance } from "fastify";
import { applyBody, checkBody, createUpdaterClient, requestId, UpdaterError } from "../modules/selfhost-updater";
import { requireSystemAdmin } from "../utils/requireSystemAdmin";
import { getBuildInfo } from "../utils/buildInfo";
export default async function selfhostUpdateRoutes(server: FastifyInstance, options: { client?: ReturnType<typeof createUpdaterClient> }) {
const client = options.client || createUpdaterClient();
server.addHook("preHandler", async (req, reply) => {
reply.header("Cache-Control", "no-store");
await requireSystemAdmin(server, req, reply);
});
const forward = async (reply: any, method: "GET" | "POST", path: string, body?: unknown) => {
try {
const result = await client(method, path, body);
return reply.code(result.status).send(path === "/v1/status"
? { ...result.body as object, backend: getBuildInfo() } : result.body);
} catch (error) {
const safe = error instanceof UpdaterError ? error : new UpdaterError("UPDATER_UNAVAILABLE");
return reply.code(safe.status).send({ error: { code: safe.code, message: safe.message } });
}
};
server.get("/admin/updates/status", async (_req, reply) => forward(reply, "GET", "/v1/status"));
server.get<{ Params: { id: string } }>("/admin/updates/requests/:id", async (req, reply) => {
if (!requestId.safeParse(req.params.id).success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
return forward(reply, "GET", `/v1/requests/${req.params.id}`);
});
for (const command of ["check", "apply"] as const) {
server.post(`/admin/updates/${command}`, { bodyLimit: 4096 }, async (req, reply) => {
// The auth plugin verifies this JWT. Cookies alone cannot trigger host operations (CSRF).
if (!/^Bearer \S{11,}$/.test(req.headers.authorization || "")) {
return reply.code(403).send({ error: { code: "BEARER_REQUIRED", message: "Bitte erneut anmelden." } });
}
const parsed = (command === "apply" ? applyBody : checkBody).safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: { code: "INVALID_REQUEST" } });
return forward(reply, "POST", `/v1/${command}`, parsed.data);
});
}
}

View File

@@ -23,7 +23,8 @@ import {
vendors,
bankaccounts,
bankstatements,
ownaccounts
ownaccounts,
costcentres
} from "../../../db/schema";
dayjs.extend(isBetween);
@@ -80,6 +81,29 @@ const formatDatevDate = (date: dayjs.ConfigType, format: string) => {
return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : "";
};
export const getBankAccountBookingLine = (alloc: any) => {
const bs = alloc.bankstatement;
const account = alloc.account || alloc.ownaccount;
const columns = Array(124).fill("");
const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`;
columns[0] = displayCurrency(Number(alloc.amount), true);
columns[1] = quote(Number(alloc.amount) < 0 ? "H" : "S");
columns[6] = bs.account?.datevNumber || "";
columns[7] = account.number;
columns[8] = quote(alloc.datevTaxKey || "");
columns[9] = formatDatevDate(bs.date, "DDMM");
columns[13] = quote(`${Number(alloc.amount) > 0 ? "ZE" : "ZA"} ${account.number} - ${account.label || account.name || ""} ${alloc.description || ""} ${bs.text || ""}`.substring(0, 59));
columns[20] = quote("Geschäftspartner");
columns[21] = quote(bs.debName || bs.credName || "");
columns[28] = quote("Belegdatum");
columns[29] = quote(formatDatevDate(bs.date, "DD.MM.YYYY"));
columns[36] = quote(alloc.costcentre?.number || "");
columns[47] = "Bank-Id";
columns[48] = bs.id;
columns[112] = "0";
return columns.join(";");
};
const getIncomingInvoiceTaxOverride = (invoice: any) => {
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
@@ -136,7 +160,7 @@ export async function buildExportZip(
const endDateFmt = formatDatevDate(endDate, "YYYYMMDD");
let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`;
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;Sachverhalt;Zinssperre;Beleglink;Beleginfo - Art 1;Beleginfo - Inhalt 1;Beleginfo - Art 2;Beleginfo - Inhalt 2;Beleginfo - Art 3;Beleginfo - Inhalt 3;Beleginfo - Art 4;Beleginfo - Inhalt 4;Beleginfo - Art 5;Beleginfo - Inhalt 5;Beleginfo - Art 6;Beleginfo - Inhalt 6;Beleginfo - Art 7;Beleginfo - Inhalt 7;Beleginfo - Art 8;Beleginfo - Inhalt 8;KOST1 - Kostenstelle;KOST2 - Kostenstelle;Kost Menge;EU-Land u. USt-IdNr. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;Zusatzinformation - Art 1;Zusatzinformation - Inhalt 1;Zusatzinformation - Art 2;Zusatzinformation - Inhalt 2;Zusatzinformation - Art 3;Zusatzinformation - Inhalt 3;Zusatzinformation - Art 4;Zusatzinformation - Inhalt 4;Zusatzinformation - Art 5;Zusatzinformation - Inhalt 5;Zusatzinformation - Art 6;Zusatzinformation - Inhalt 6;Zusatzinformation - Art 7;Zusatzinformation - Inhalt 7;Zusatzinformation - Art 8;Zusatzinformation - Inhalt 8;Zusatzinformation - Art 9;Zusatzinformation - Inhalt 9;Zusatzinformation - Art 10;Zusatzinformation - Inhalt 10;Zusatzinformation - Art 11;Zusatzinformation - Inhalt 11;Zusatzinformation - Art 12;Zusatzinformation - Inhalt 12;Zusatzinformation - Art 13;Zusatzinformation - Inhalt 13;Zusatzinformation - Art 14;Zusatzinformation - Inhalt 14;Zusatzinformation - Art 15;Zusatzinformation - Inhalt 15;Zusatzinformation - Art 16;Zusatzinformation - Inhalt 16;Zusatzinformation - Art 17;Zusatzinformation - Inhalt 17;Zusatzinformation - Art 18;Zusatzinformation - Inhalt 18;Zusatzinformation - Art 19;Zusatzinformation - Inhalt 19;Zusatzinformation - Art 20;Zusatzinformation - Inhalt 20;Stueck;Gewicht;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`;
// ---------------------------------------------------------
// 1. DATEN LADEN (CORE API SELECT & JOIN)
@@ -209,6 +233,7 @@ export async function buildExportZip(
direct_vend: vendors, // Direkte Zuordnung an Kreditor
direct_cust: customers, // Direkte Zuordnung an Debitor
own: ownaccounts,
costcentre: costcentres,
contra_acc: ContraAccount,
contra_vend: ContraVendor,
contra_cust: ContraCustomer,
@@ -233,6 +258,7 @@ export async function buildExportZip(
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id))
.leftJoin(costcentres, and(eq(statementallocations.costcentre, costcentres.id), eq(costcentres.tenant, tenantId)))
.leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id))
@@ -257,10 +283,10 @@ export async function buildExportZip(
// Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
const statementallocationsList = allocRaw.map(r => ({
...r.allocation,
bankstatement: {
bankstatement: r.bs ? {
...r.bs,
account: r.ba // Nesting für bs.account.datevNumber
},
} : null,
createddocument: r.cd ? {
...r.cd,
customer: r.cd_cust
@@ -273,6 +299,7 @@ export async function buildExportZip(
vendor: r.direct_vend,
customer: r.direct_cust,
ownaccount: r.own,
costcentre: r.costcentre,
contraAccount: r.contra_acc,
contraVendor: r.contra_vend,
contraCustomer: r.contra_cust,
@@ -453,9 +480,7 @@ export async function buildExportZip(
const vend = ii.vendor;
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.account) {
const acc = alloc.account;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
bookingLines.push(getBankAccountBookingLine(alloc));
} else if(alloc.vendor) {
const vend = alloc.vendor;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
@@ -465,9 +490,7 @@ export async function buildExportZip(
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
} else if(alloc.ownaccount) {
const own = alloc.ownaccount;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA";
bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`);
bookingLines.push(getBankAccountBookingLine(alloc));
}
});

View File

@@ -0,0 +1,18 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { eq } from "drizzle-orm";
import { authUsers } from "../../db/schema/auth_users";
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
if (!req.user?.user_id) {
reply.code(401).send({ error: "Unauthorized" });
return null;
}
// Read the current global role; tenant roles and stale JWT claims do not grant access.
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
.from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1);
if (!user?.is_admin) {
reply.code(403).send({ error: "Admin access required" });
return null;
}
return user;
}

View File

@@ -0,0 +1,69 @@
import assert from "node:assert/strict"
import test from "node:test"
import { PgDialect } from "drizzle-orm/pg-core"
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../src/modules/bank-allocation-details"
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
import bankingRoutes from "../src/routes/banking"
import { statementallocations } from "../db/schema"
const costcentre = "57d31d62-11a2-47c5-b074-69f9c5ab8bba"
test("keeps cost centre and VAT key on the gross bank allocation through input preparation", () => {
const prepared = prepareStatementAllocationInput({ bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: " 9 " })
assert.equal(prepared.amount, -119)
assert.equal(prepared.costcentre, costcentre)
assert.equal(prepared.datevTaxKey, "9")
assert.deepEqual(normalizeBankAllocationDetails({ amount: -119, account: 13, bankstatement: 42 }), { costcentre: null, datevTaxKey: null })
})
test("rejects invalid cost centres, ambiguous document assignments and malformed tax keys", () => {
assert.throws(() => normalizeBankAllocationDetails({ costcentre: "invalid" }), /UUID/)
assert.throws(() => normalizeBankAllocationDetails({ costcentre, bankstatement: 1, account: 13, incominginvoice: 2 }), /direkte Bankbuchungen/)
assert.throws(() => normalizeBankAllocationDetails({ datevTaxKey: '9";evil' }), /Steuerschlüssel/)
})
test("cost centre lookup requires the active tenant and excludes archived centres", async () => {
let condition: any
const query = { from: () => query, where: (value: any) => { condition = value; return query }, limit: async () => [] }
await assert.rejects(validateBankAllocationCostCentre({ select: () => query }, 7, costcentre), /nicht gefunden oder ist archiviert/)
const sql = new PgDialect().sqlToQuery(condition)
assert.match(sql.sql, /"costcentres"\."tenant"/)
assert.match(sql.sql, /"costcentres"\."archived"/)
assert.deepEqual(sql.params, [costcentre, 7, false])
await validateBankAllocationCostCentre({ select: () => ({ ...query, from: () => ({ where: () => ({ limit: async () => [{ id: costcentre }] }) }) }) }, 7, costcentre)
await validateBankAllocationCostCentre({}, 7, null)
})
test("banking API persists the gross allocation with cost centre and VAT, and rejects unknown centres before writing", async () => {
const handlers = new Map<string, any>()
const saved: any[] = []
let centreExists = true
const lookup: any = { from: () => lookup, where: () => lookup, limit: async () => centreExists ? [{ id: costcentre }] : [] }
const server: any = {
get() {}, patch() {}, delete() {},
post: (url: string, handler: any) => handlers.set(url, handler),
db: {
select: () => lookup,
insert: (table: any) => ({ values: (value: any) => {
if (table === statementallocations) saved.push(value)
return { returning: async () => [{ id: "allocation", ...value }] }
} }),
},
}
await bankingRoutes(server)
const handler = handlers.get("/banking/statements")
const request = { user: { tenant_id: 7, user_id: "user" }, body: { data: { bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: "9" } } }
let status = 200
let response: any
const reply = { code: (code: number) => { status = code; return reply }, send: (body: any) => { response = body; return reply } }
await handler(request, reply)
assert.equal(status, 200)
assert.equal(response.amount, -119)
assert.equal(response.costcentre, costcentre)
assert.equal(response.datevTaxKey, "9")
assert.equal(saved[0].tenant, 7)
centreExists = false
await handler(request, reply)
assert.equal(status, 400)
assert.equal(saved.length, 1)
})

View File

@@ -3,6 +3,28 @@ import { test } from 'node:test';
import Fastify from 'fastify';
import healthRoutes from '../src/routes/health';
test('health checks the database and reports a failure without leaking connection details', async () => {
const app = Fastify();
let available = true;
app.decorate('db', { execute: async () => {
if (!available) throw new Error('postgres://secret@database');
return { rows: [{ result: 1 }] };
} } as any);
try {
await app.register(healthRoutes);
const healthy = await app.inject('/health');
assert.equal(healthy.statusCode, 200);
assert.equal(healthy.json().status, 'ok');
assert.equal(healthy.headers['cache-control'], 'no-store');
available = false;
const unhealthy = await app.inject('/health');
assert.equal(unhealthy.statusCode, 503);
assert.deepEqual(unhealthy.json(), { status: 'unavailable' });
} finally {
await app.close();
}
});
test('version endpoint reports image metadata without a database or authentication', async () => {
const previousVersion = process.env.FEDEO_VERSION;
const previousRevision = process.env.FEDEO_REVISION;

View File

@@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import test from "node:test";
import { getCreatedDocumentRevenueLines } from "../src/utils/export/datev";
import { buildExportZip, getBankAccountBookingLine, getCreatedDocumentRevenueLines } from "../src/utils/export/datev";
import { Uint8ArrayReader, TextWriter, ZipReader } from "@zip.js/zip.js";
import { statementallocations } from "../db/schema";
test("exports gross amounts for DATEV automatic revenue accounts", () => {
const revenueLines = getCreatedDocumentRevenueLines({
@@ -27,3 +29,77 @@ test("preserves negative gross amounts for cancellations", () => {
assert.deepEqual(revenueLines, [{ account: "8400", amount: -119 }]);
});
for (const [amount, taxKey, side] of [[-119, "9", "H"], [-107, "8", "H"], [119, "3", "S"], [107, "2", "S"], [119, "9", "S"], [-119, "3", "H"], [-100, "40", "H"]] as const) {
test(`exports gross ${amount} with tax key ${taxKey} and cost centre`, () => {
const columns = getBankAccountBookingLine({
amount, datevTaxKey: taxKey, account: { number: 4900, label: "Konto" },
costcentre: { id: "internal-uuid", number: "KST-1001" },
bankstatement: { id: 42, date: "2026-09-23", account: { datevNumber: "1200" } },
}).split(";");
assert.equal(columns.length, 124);
assert.equal(columns[0], `${Math.abs(amount)},00`);
assert.equal(columns[1], `"${side}"`);
assert.equal(columns[6], "1200");
assert.equal(columns[7], "4900");
assert.equal(columns[8], `"${taxKey}"`);
assert.equal(columns[36], '"KST-1001"');
assert.equal(columns[112], "0");
});
}
test("exports legacy allocations and additional accounts without inventing a tax key", () => {
const columns = getBankAccountBookingLine({
amount: -12.34, ownaccount: { number: "4901", name: "Zusatzkonto" },
bankstatement: { id: 1, date: "2026-09-23", account: { datevNumber: "1200" } },
}).split(";");
assert.equal(columns[0], "12,34");
assert.equal(columns[7], "4901");
assert.equal(columns[8], '""');
assert.equal(columns[36], '""');
});
test("ZIP export carries allocation metadata into the matching CSV columns and leaves invoice payments untaxed", async () => {
const bank = { id: 42, date: "2026-09-23", text: "Zahlung" };
const rows = [
{ allocation: { id: "a", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, acc: { number: 4900 }, costcentre: { number: "1001" } },
{ allocation: { id: "b", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, own: { number: 4901 }, costcentre: { number: "1002" } },
{ allocation: { id: "c", amount: -119, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, ii: { id: 3, date: bank.date, reference: "ER-3" }, ii_vend: { vendorNumber: 70001 } },
{ allocation: { id: "d", amount: 119, datevTaxKey: "9", manualBookingDate: bank.date }, bs: null, acc: { number: 4900 }, contra_acc: { number: 1200 } },
];
const db = {
select: () => {
let result: any[] = [];
const query: any = {
from: (table: any) => { result = table === statementallocations ? rows : []; return query; },
leftJoin: () => query, where: () => query, orderBy: () => query,
then: (resolve: any) => Promise.resolve(result).then(resolve),
};
return query;
},
};
const archive = await buildExportZip({ db } as any, 1, "2026-09-01", "2026-09-30", "123", "456");
const reader = new ZipReader(new Uint8ArrayReader(archive));
try {
const entries = await reader.getEntries();
const csvEntry = entries.find(entry => entry.filename.startsWith("EXTF_Buchungsstapel"));
assert.ok(csvEntry && "getData" in csvEntry);
const csv = await csvEntry.getData!(new TextWriter());
const [, header, ...bookings] = csv.trim().split("\n").map(line => line.split(";"));
assert.equal(bookings.length, 4);
assert.equal(header.length, 124);
for (const [i, costcentre] of ["1001", "1002"].entries()) {
assert.equal(bookings[i].length, header.length);
assert.equal(bookings[i][header.indexOf("Umsatz")], "59,50");
assert.equal(bookings[i][header.indexOf("BU-Schluessel")], '"9"');
assert.equal(bookings[i][header.indexOf("KOST1 - Kostenstelle")], `"${costcentre}"`);
assert.equal(bookings[i][header.indexOf("Festschreibung")], "0");
}
assert.equal(bookings[2][8], '""');
assert.equal(bookings[3][8], '"9"');
assert.equal(bookings[3][6], "4900");
assert.equal(bookings[3][7], "1200");
} finally {
await reader.close();
}
});

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

View File

@@ -0,0 +1,119 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createServer } from "node:http";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import Fastify from "fastify";
import routes from "../src/routes/selfhostUpdates";
import { createUpdaterClient } from "../src/modules/selfhost-updater";
import authPlugin from "../src/plugins/auth";
import { secrets } from "../src/utils/secrets";
import jwt from "jsonwebtoken";
const id = "12345678-1234-4123-a123-123456789abc";
const headers = { authorization: "Bearer verified-jwt-token" };
async function fixture(t: any, admin = true, authenticated = true) {
const app = Fastify();
const calls: unknown[] = [];
let currentAdmin = admin;
// Mirror the parent auth plugin's preHandler order, not an earlier onRequest hook.
app.addHook("preHandler", async req => { if (authenticated) req.user = { user_id: id, is_admin: true } as any; });
app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: currentAdmin }] }) }) }) });
await app.register(routes, { client: async (...args) => {
calls.push(args); return { status: 202, body: { status: "queued", requestId: id } };
} });
t.after(() => app.close());
return { app, calls, revoke: () => { currentAdmin = false; } };
}
test("all update endpoints require a current global admin, including after revocation", async t => {
const f = await fixture(t);
assert.equal((await f.app.inject({ url: "/admin/updates/status" })).statusCode, 202);
f.revoke();
for (const [method, url] of [["GET", "/admin/updates/status"], ["GET", `/admin/updates/requests/${id}`],
["POST", "/admin/updates/check"], ["POST", "/admin/updates/apply"]] as const) {
assert.equal((await f.app.inject({ method, url, headers, payload: method === "POST" ? { requestId: id } : undefined })).statusCode, 403);
}
assert.equal(f.calls.length, 1);
});
test("unauthenticated and cookie-only requests cannot start jobs", async t => {
const anonymous = await fixture(t, true, false);
assert.equal((await anonymous.app.inject({ url: "/admin/updates/status" })).statusCode, 401);
const f = await fixture(t);
for (const authorization of [undefined, "Bearer short", "bearer long-but-not-used-by-auth"]) {
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check",
headers: authorization ? { authorization } : {}, payload: { requestId: id } })).statusCode, 403);
}
assert.equal(f.calls.length, 0);
});
test("fixed routes reject injected options, malformed IDs and unreviewed apply requests", async t => {
const f = await fixture(t);
for (const payload of [{ requestId: id, directory: "/root" }, { requestId: id, version: "latest" },
{ requestId: "../../etc/passwd" }, { requestId: id, acceptComposeReplacement: true }]) {
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/check", headers, payload })).statusCode, 400);
}
assert.equal((await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers,
payload: { requestId: id, version: "3.2.0" } })).statusCode, 400);
assert.equal(f.calls.length, 0);
const response = await f.app.inject({ method: "POST", url: "/admin/updates/apply", headers,
payload: { requestId: id, version: "3.2.0", checkRequestId: "checked-request" } });
assert.equal(response.statusCode, 202);
assert.equal(response.headers["cache-control"], "no-store");
assert.deepEqual(f.calls[0], ["POST", "/v1/apply", { requestId: id, version: "3.2.0", checkRequestId: "checked-request" }]);
});
test("real authentication requires a valid header JWT even when a valid cookie exists", async t => {
const app = Fastify();
t.after(() => app.close());
const previous = secrets.JWT_SECRET;
secrets.JWT_SECRET = "update-route-test-secret";
t.after(() => { secrets.JWT_SECRET = previous; });
const token = jwt.sign({ user_id: id, tenant_id: null, is_admin: true }, secrets.JWT_SECRET, { expiresIn: "5m" });
app.addHook("onRequest", async req => { req.cookies = { token }; });
app.decorate("db", { select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ id, is_admin: true }] }) }) }) });
await app.register(authPlugin);
let forwarded = 0;
await app.register(routes, { client: async () => { forwarded++; return { status: 202, body: { status: "queued" } }; } });
for (const authorization of ["Bearer x", "bearer ignored-token-with-valid-cookie", "Bearer invalid-long-jwt-token"]) {
const result = await app.inject({ method: "POST", url: "/admin/updates/check", headers: { authorization }, payload: { requestId: id } });
assert.ok([401, 403].includes(result.statusCode));
}
assert.equal(forwarded, 0);
assert.equal((await app.inject({ method: "POST", url: "/admin/updates/check",
headers: { authorization: `Bearer ${token}` }, payload: { requestId: id } })).statusCode, 202);
assert.equal(forwarded, 1);
});
async function socketFixture(t: any, handler: Parameters<typeof createServer>[0]) {
const directory = await mkdtemp(`${tmpdir()}/fedeo-api-`);
const socket = `${directory}/control.sock`;
const server = createServer(handler);
await new Promise<void>(resolve => server.listen(socket, resolve));
t.after(async () => { server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); await rm(directory, { recursive: true, force: true }); });
return createUpdaterClient(socket, 150);
}
test("Unix client strips private fields and never forwards credentials or raw error messages", async t => {
const client = await socketFixture(t, (req, res) => {
assert.equal(req.headers.authorization, undefined);
assert.equal(req.headers.cookie, undefined);
if (req.url === "/v1/status") res.end(JSON.stringify({ apiVersion: 1, acceptingRequests: true,
engine: { status: "idle", job: "/secret/path", error: "password" }, installed: {}, secret: "token" }));
else { res.writeHead(409); res.end(JSON.stringify({ error: { code: "PLAN_CHANGED", message: "password" } })); }
});
const response = await client("GET", "/v1/status");
assert.deepEqual(response.body, { apiVersion: 1, acceptingRequests: true, engine: { status: "idle" }, installed: {} });
await assert.rejects(client("POST", "/v1/apply", {}), { code: "PLAN_CHANGED", status: 409,
message: "Release oder Konfiguration haben sich geändert. Bitte erneut prüfen." });
await assert.rejects(client("GET", "http://example.com"), { code: "INVALID_REQUEST" });
});
test("Unix client bounds response size, deadline, and protocol shape", async t => {
for (const scenario of ["oversize", "timeout", "invalid"] as const) {
await t.test(scenario, async t => {
const client = await socketFixture(t, (_req, res) => {
if (scenario === "oversize") res.end("x".repeat(70000));
else if (scenario === "invalid") res.end('{"apiVersion":2}');
});
await assert.rejects(client("GET", "/v1/status"), (error: any) =>
["UPDATER_UNAVAILABLE", "UPDATER_PROTOCOL"].includes(error.code) && !error.message.includes("ENOENT"));
});
}
});

View File

@@ -48,7 +48,7 @@ services:
- internal
minio:
image: minio/minio:latest
image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e
container_name: fedeo-minio
restart: unless-stopped
command: server /data --console-address ":9001"
@@ -66,13 +66,13 @@ services:
- internal
createbuckets:
image: minio/mc:latest
image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727
container_name: fedeo-minio-init
depends_on:
minio:
condition: service_healthy
entrypoint: >
/bin/sh -c "
/bin/sh -ec "
mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD};
mc mb --ignore-existing local/${MINIO_BUCKET};
mc anonymous set private local/${MINIO_BUCKET};
@@ -86,6 +86,9 @@ services:
image: git.federspiel.tech/flfeders/fedeo/backend:${FEDEO_VERSION:-dev}
container_name: fedeo-backend
restart: unless-stopped
volumes:
# Mount the directory so a service restart can replace the Unix socket.
- /run/fedeo-updater:/run/fedeo-updater:ro
depends_on:
db:
condition: service_healthy
@@ -143,6 +146,12 @@ services:
FEDEO_BOOTSTRAP_TENANT_NAME: ${FEDEO_BOOTSTRAP_TENANT_NAME:-FEDEO}
FEDEO_BOOTSTRAP_TENANT_SHORT: ${FEDEO_BOOTSTRAP_TENANT_SHORT:-FEDEO}
NODE_EXPORTER_URL: ${NODE_EXPORTER_URL:-http://node-exporter:9100}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:' + (process.env.PORT || '3100') + '/health', {signal: AbortSignal.timeout(4000)}).then(r => {if (!r.ok) process.exit(1)}).catch(() => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 6
start_period: 120s
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-backend.rule=Host(`${DOMAIN}`) && PathPrefix(`/backend`)
@@ -178,11 +187,18 @@ services:
container_name: fedeo-frontend
restart: unless-stopped
depends_on:
- backend
backend:
condition: service_healthy
environment:
NODE_ENV: production
NUXT_PUBLIC_API_BASE: https://${DOMAIN}/backend
NUXT_PUBLIC_PDF_LICENSE: ${NUXT_PUBLIC_PDF_LICENSE}
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/api/health', {signal: AbortSignal.timeout(4000)}).then(r => {if (!r.ok) process.exit(1)}).catch(() => process.exit(1))"]
interval: 10s
timeout: 5s
retries: 6
start_period: 30s
labels:
- traefik.enable=true
- traefik.http.routers.fedeo-frontend.rule=Host(`${DOMAIN}`)

View File

@@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
## Direkt auf ein Buchungskonto buchen
Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen.
- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um.
- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll.
- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle.
Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal.
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

View File

@@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist.
## Direkt auf ein Buchungskonto buchen
Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen.
- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um.
- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll.
- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle.
Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal.
Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False).
## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

View File

@@ -53,7 +53,7 @@ Backend, Frontend, Dokumentation, Website und zentrale Dienste werden als gemein
- Bewegliche Kanäle: `latest` und `main` zeigen nach erfolgreicher Veröffentlichung auf denselben Release.
- Entwicklung: `dev` sowie unveränderliche `dev-sha-<vollständiger Commit>-<UUID>`-Images. Alle Komponenten eines erfolgreichen Laufs erhalten denselben Snapshot-Tag. Auch eine Wiederholung desselben Commits baut einen eigenen Snapshot. Die eingebettete Entwicklungsversion enthält Commit-Anzahl und Commit-ID.
- Release-Builds erhalten zunächst einen eindeutigen temporären `build-<UUID>`-Tag. Diese Tags können später nach einer Aufbewahrungsfrist bereinigt werden; stabile Versionen und deren Digests müssen erhalten bleiben.
- Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh` und `SHA256SUMS`.
- Release-Dateien: `CHANGELOG.md`, `release-manifest.json`, `docker-compose.selfhost.yml`, `.env.example`, `selfhost-install.sh`, `selfhost-setup.sh`, `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh`, `fedeo-updater.service`, `SELFHOST-UPDATES.md` und `SHA256SUMS`.
Das Manifest dokumentiert die Image-Digests, den Commit und SHA-256-Prüfsummen aller enthaltenen SQL-Migrationen. Es beschreibt den ausgelieferten Migrationsstand; den tatsächlich angewendeten Stand einer Installation bestimmt deren Datenbank.
@@ -122,7 +122,9 @@ bash /tmp/fedeo-install.sh --simple --start
Dieser versionierte Installer lädt die Betriebsdateien vom zugehörigen Git-Tag und schreibt `FEDEO_VERSION=3.0.0` in die neue `.env`. Vorhandene Installationen behalten ihren bisherigen Kanal, bis `FEDEO_VERSION` bewusst geändert wird. Die Dateien im Entwicklungszweig verwenden weiterhin `dev` als Rückfallwert.
Bei einem Update:
Der vollständige Host-Updater einschließlich Compose-Austausch und Backup ist in [Vollständige Selfhost-Updates](selfhost-updates.md) beschrieben. Er benötigt eine Zielversion mit gepinntem MinIO und Anwendungs-Healthchecks.
Manueller Ablauf für ältere Releases oder abweichende Installationen:
1. Release Notes und Migrationshinweise lesen. Konsistentes Datenbankbackup (z. B. `pg_dump`) sowie Datei-/Objektspeicher und Konfiguration sichern. Ein TAR des laufenden PostgreSQL-Datenverzeichnisses ist kein verlässliches Datenbankbackup.
2. Die benötigten Betriebsdateien vom gewünschten Release herunterladen; bestehende `.env` und Daten behalten. Downloads mit dessen `SHA256SUMS` überprüfen.

198
docs/selfhost-updates.md Normal file
View File

@@ -0,0 +1,198 @@
# Vollständige Selfhost-Updates
`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und den optionalen Host-Dienst `fedeo-updater.service`. Beide laufen auf dem Docker-Host außerhalb der FEDEO-Container und verwenden dieselbe exklusive Sperre. Das Backend verbindet diese lokale Schnittstelle mit dem Update-Bereich unter Administration → Systemstatus.
## Voraussetzungen und Umfang
- Linux/amd64, Python ab 3.10, Docker Compose ab 2.24, `tar` und `du`.
- Bestehendes Betriebsverzeichnis, standardmäßig `/opt/fedeo`, mit `.env` und `docker-compose.yml`; kein Git-Checkout.
- Standard-Stack mit `traefik`, `db`, `minio`, `createbuckets`, `backend`, `frontend` und `node-exporter`. PostgreSQL und MinIO verwenden die lokalen Verzeichnisse `postgres/` und `minio/`.
- Als root auf dem Server ausführen: Docker-Zugriff und Leserechte für die Daten- und Zertifikatsverzeichnisse werden benötigt. Ein entfernter Docker-Daemon wird abgewiesen.
- Die öffentliche HTTPS-Adresse muss vom Host erreichbar sein und ein gültiges Zertifikat haben.
- Eine Wartungsunterbrechung für alle Mandanten einplanen. Vor dem Backup werden alle laufenden Dienste außer PostgreSQL angehalten, auch der öffentliche Proxy und alle im Backend laufenden Hintergrundaufgaben. Eine separate Wartungsseite folgt mit der späteren Oberflächenintegration.
Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben.
Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die sieben FEDEO-Dienste enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf.
Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen.
Die Stilllegung vor der Dateisicherung entspricht den Anforderungen an ein [PostgreSQL-Dateisystem-Backup](https://www.postgresql.org/docs/16/backup-file.html); die Container-Richtlinie wird mit [Docker `update --restart`](https://docs.docker.com/reference/cli/docker/container/update/) geändert.
Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Frontend-Healthchecks enthalten. Ältere Releases bleiben über den in `docs/releases.md` beschriebenen manuellen Ablauf installierbar. Ein Versionswechsel des Betriebssystems oder der Docker Engine gehört nicht zu diesem Ablauf.
## Bereitstellung
Neue versionierte Installer laden das Skript und diese Anleitung bereits ins Betriebsverzeichnis und berücksichtigen Python 3 bei den Basispaketen. Bestehende Installationen können `selfhost-update.py` und `SELFHOST-UPDATES.md` aus den Assets eines entsprechenden veröffentlichten Releases übernehmen. Zuerst mit dessen `SHA256SUMS` prüfen, anschließend das Skript unter `/opt/fedeo/scripts/selfhost-update.py` ablegen. Für diese erstmalige Bereitstellung ist noch ein Serverzugriff erforderlich.
Ein bereits heruntergeladener Updater aus Release `3.1.0` enthält die Matrix-Stilllegung noch nicht. Dafür das Hostskript durch den korrigierten Stand aus einem neueren Release oder dem geprüften Repository-Commit ersetzen. Dieser kann weiterhin `3.1.0` als Zielrelease verwenden; dessen veröffentlichte Assets werden nicht verändert. Ein erneuter Download des alten `3.1.0`-Updater-Assets liefert weiterhin den alten Stand. Der Updater ersetzt sich beim Anwenden nicht selbst.
Downloads erfolgen ausschließlich vom konfigurierten FEDEO-Repository über HTTPS. Die Dateiprüfsummen werden gegen `SHA256SUMS`, das Manifest gegen den Git-Tag geprüft. Die Prüfsummen sind **keine unabhängige Signatur**; das Vertrauen liegt beim Gitea-Server und dessen Release-Veröffentlichung. Falls das Repository privat ist, `FEDEO_RELEASE_TOKEN` mit Leserechten nur für den Aufruf setzen. Registry-Anmeldung bei Bedarf vorher über `docker login git.federspiel.tech` im Konto des Updaters durchführen. Der Updater speichert den Release-Token nicht in Statusdateien und gibt ihn bei Weiterleitungen an andere Hosts nicht weiter.
## Prüfen, anwenden und Status lesen
Neueste stabile Version suchen oder eine bestimmte Version prüfen:
```bash
sudo python3 /opt/fedeo/scripts/selfhost-update.py check
sudo python3 /opt/fedeo/scripts/selfhost-update.py check --version 3.1.0
```
`3.1.0` ist ein Beispiel und muss durch einen tatsächlich veröffentlichten Zielrelease ersetzt werden. Ein anderes Betriebsverzeichnis wird mit `--directory /pfad/zur/instanz` angegeben.
Die Prüfung lädt die Betriebsdateien und erstellt einen Plan einschließlich `compose.diff` und Release Notes unter `/opt/fedeo/.updates/<Auftrag>/`. Sie verändert die laufende Installation nicht und lädt noch keine Docker-Images. Die JSON-Ausgabe nennt Zielversion, Commit, betroffene Dienste, geschätzten Backup-Platz und die Pfade der Vorschau. `updateAvailable: false` bedeutet, dass die geprüfte Version nicht neuer als die installierte ist. Unveröffentlichte Entwürfe und Vorabversionen werden ignoriert.
Beim ersten Update kennt der Updater die Herkunft der vorhandenen Compose-Datei noch nicht. Die erzeugte `compose.diff` prüfen und eigene Anpassungen in die Override-Datei übertragen. Dann den Austausch ausdrücklich akzeptieren:
```bash
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.1.0 --accept-compose-replacement
```
Für spätere Updates unveränderter, vom Updater verwalteter Compose-Dateien:
```bash
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.2.0
sudo python3 /opt/fedeo/scripts/selfhost-update.py status
```
Eine explizite Zielversion ist beim Anwenden erforderlich. `apply` prüft die Release-Dateien und die tatsächliche Installation nochmals. Beim Anwenden gleicher oder älterer stabiler Zielversionen wird abgebrochen. Änderungen an `.env`, Basis-Compose oder der Override-Datei während des Downloads führen ebenfalls vor der Wartungsphase zum Abbruch.
Direkte CLI-Aufrufe in einer persistenten Terminalsitzung ausführen. Alternativ übernimmt der folgende Host-Dienst die von der Verbindung unabhängige Ausführung. Ein Neustart des FEDEO-Stacks beendet diese Host-Prozesse nicht. Ein erzwungener Host-Neustart kann sie unterbrechen; beim normalen Dienst-Stop wartet der Dienst auf den laufenden Auftrag.
## Host-Dienst installieren
Benötigt zusätzlich einen Linux-Host mit systemd und Docker als `docker.service`. Die Dateien `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh` und `fedeo-updater.service` müssen aus demselben geprüften Stand im selben Verzeichnis liegen, beispielsweise `/opt/fedeo/scripts/`. Neuere Installer laden sie dorthin; neuere Releases enthalten alle vier Dateien samt Prüfsummen. Release `3.1.0` enthält den Dienst noch nicht. Zum Nachrüsten können die vier Dateien aus dem geprüften Repository-Commit kopiert werden, ohne die laufende FEDEO-Version zu ändern.
```bash
sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo
sudo systemctl status fedeo-updater.service
sudo curl --unix-socket /run/fedeo-updater/control.sock http://localhost/v1/status
```
Der Installer legt die Python-Module root-eigen unter `/usr/local/lib/fedeo-updater/` ab, installiert die systemd-Unit und aktiviert den Dienst beim Systemstart. Bestehende FEDEO-Container werden durch die Dienstinstallation nicht neu gestartet. Bei einer erneuten Dienstinstallation wartet ein normales `systemctl stop` einen laufenden Auftrag ab; ein aktiver CLI-Updater blockiert den Dateiaustausch. Die Unit kann vorab ohne Installation angezeigt werden:
```bash
bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo --print-unit
```
Der Dienst läuft wegen Docker und der Backups als root. Seine HTTP-Schnittstelle lauscht ausschließlich auf `/run/fedeo-updater/control.sock`, nicht auf einem TCP-Port. Verzeichnis und Socket gehören `root:fedeo-updater` mit `0750` bzw. `0660`. Zugriff auf diese Gruppe erlaubt instanzweite Updates; nur vertrauenswürdigen Host-Prozessen Zugriff geben. Die Selfhost-Compose-Datei bindet `/run/fedeo-updater` schreibgeschützt unter demselben Pfad im Backend ein. Das Verzeichnis statt der einzelnen Socket-Datei bleibt auch bei einem Socket-Austausch durch Dienstneustarts nutzbar. Die Standard-Backend-Container laufen als root; bei einer eigenen unprivilegierten Konfiguration benötigt der Backend-Prozess zusätzlich die Host-GID von `fedeo-updater`. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Ohne installierten Dienst bleibt die Anwendung nutzbar; der Update-Bereich meldet den Dienst als nicht erreichbar.
Registry-Anmeldung bei privaten Images als root durchführen. Für private Releases kann `/etc/fedeo/updater.env` root-lesbar (`0600`) `FEDEO_RELEASE_TOKEN=...` enthalten; danach den Dienst neu starten. Dieser Token wird nicht an API-Clients ausgegeben. Die Unit verwendet `/root` als Home-Verzeichnis für die Docker-Anmeldung. Der Dienst ersetzt sich bei einem FEDEO-Update nicht selbst; neue Dienstdateien zusammen bereitstellen und den Dienstinstaller erneut ausführen.
## Updates im Frontend und einmaliger Übergang
Systemadministratoren öffnen **Administration → Systemstatus → FEDEO-Updates**. „Nach Updates suchen“ prüft den neuesten stabilen Release. Die Oberfläche zeigt den laufenden Backend-Stand, die geprüfte Zielversion, den Commit, den geschätzten Backup-Platz und gegebenenfalls stillzulegende Matrix-Dienste. „Update auf … installieren“ öffnet die Bestätigung für die Wartungsunterbrechung aller Mandanten. Erst „Jetzt installieren“ nach Bestätigung startet den Auftrag.
Der Fortschritt wird automatisch abgefragt. Während des Stack-Neustarts ist die Verbindung vorübergehend unterbrochen; der Host arbeitet weiter. Nach Erfolg lädt „Neue Version laden“ die Oberfläche neu. Auftrags-IDs bleiben im Browser gespeichert und der Dienst liefert auch nach seinem Neustart den letzten Auftrag. Geht die Startantwort verloren, fragt die Oberfläche zuerst dieselbe ID ab. „Auftrag erneut senden“ verwendet dieselbe ID und denselben Inhalt; es wird kein zweites Update gestartet. Nach einer erneuten Anmeldung die Systemstatus-Seite wieder öffnen. Auch ohne Browser-Speicher lässt sich der aktuelle Auftrag über den Dienststatus verfolgen.
Für eine bestehende Instanz auf `3.1.0` ist noch ein einmaliger Übergang nötig, **nachdem ein Release mit diesem Update-Bereich veröffentlicht wurde**:
1. Die vier zusammengehörigen Host-Dateien aus dessen geprüften Release-Assets nach `/opt/fedeo/scripts/` übernehmen: `selfhost-update.py`, `selfhost-update-service.py`, `selfhost-update-service-install.sh`, `fedeo-updater.service`.
2. Den Dienstinstaller erneut ausführen, damit auch `/usr/local/lib/fedeo-updater/` den aktuellen Stand erhält.
3. Einmal per CLI auf diesen Release aktualisieren. `X.Y.Z` durch die veröffentlichte Version ersetzen:
```bash
sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh --directory /opt/fedeo
sudo python3 /opt/fedeo/scripts/selfhost-update.py check --version X.Y.Z
sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version X.Y.Z
```
Der aktuelle Updater erlaubt dabei gezielt das zusätzliche, schreibgeschützte Standard-Socket-Verzeichnis am Backend. Änderungen an bestehenden Daten-Mounts sowie abweichende oder schreibbare neue Kontroll-Mounts bleiben gesperrt. Ältere Updater besitzen diese Übergangsregel noch nicht. Ein bereits erfolgreich verwalteter Compose-Stand benötigt keine erneute `--accept-compose-replacement`-Freigabe. Falls der Prüfplan sie verlangt, zunächst die Compose-Differenz auf dem Host prüfen wie oben beschrieben.
Danach laufen weitere Anwendungs-Releases einschließlich Compose und Migrationen über den Button. Änderungen am Host-Dienst selbst benötigen weiterhin die gemeinsame Aktualisierung der Host-Dateien und den Dienstinstaller.
Die Backend-Routen `/api/admin/updates/status`, `/api/admin/updates/requests/:id`, `/api/admin/updates/check` und `/api/admin/updates/apply` prüfen bei jeder Anfrage die aktuellen globalen Administratorrechte aus der Datenbank. Mandantenadministratoren haben keinen Zugriff. Startanfragen benötigen zusätzlich den bereits von der Authentifizierung geprüften Bearer-JWT; Cookie-Anmeldung allein kann keine Hostaktion auslösen. Das Backend verwendet ausschließlich den festen Unix-Socket, begrenzt Antwortgröße und Wartezeit und liefert nur erlaubte Status-/Planfelder aus. CLI-Wiederherstellung, Übernahme fremder Compose-Dateien, Shell-Befehle und frei wählbare Download-Adressen bleiben auf dem Host.
## Lokale Dienst-API
Alle Antworten sind JSON mit `Cache-Control: no-store`. Mutationen benötigen `Content-Type: application/json`, höchstens 4096 Byte und eine vom Client erzeugte `requestId` (8–80 Zeichen, Buchstaben/Ziffern/Unterstrich/Bindestrich). Für jede neue Aktion eine neue ID verwenden; bei einem Verbindungsfehler dieselbe ID und denselben Inhalt wiederholen. Das liefert denselben Auftrag statt eines zweiten Updates, auch nach einem Dienstneustart. Eine andere Anfrage mit derselben ID wird mit `409 REQUEST_CONFLICT` abgelehnt.
| Aufruf | Bedeutung |
| --- | --- |
| `GET /v1/status` | Installierter Stand, Zustand der Update-Engine und aktueller Dienstauftrag |
| `POST /v1/check` | Asynchrone Prüfung; `requestId`, optional `version` ohne `v` |
| `GET /v1/requests/<requestId>` | Auftrag, bereinigter Prüfplan und aktueller Fortschritt |
| `POST /v1/apply` | Asynchrones Update; `requestId`, feste `version` und erfolgreiche `checkRequestId` |
Beispiel für eine Prüfung des neuesten Releases:
```bash
CHECK_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
curl --unix-socket /run/fedeo-updater/control.sock \
-H 'Content-Type: application/json' \
-d "{\"requestId\":\"$CHECK_ID\"}" http://localhost/v1/check
curl --unix-socket /run/fedeo-updater/control.sock \
"http://localhost/v1/requests/$CHECK_ID"
```
Neue Aufträge antworten sofort mit `202`. `queued` und `running` bedeuten, dass die Arbeit noch läuft; `checked` bedeutet erfolgreiche Prüfung und `up_to_date`, dass keine neuere Version vorliegt. Der bereinigte Plan enthält Version, Commit, Platzbedarf und stillzulegende Dienste. Vollständige Release Notes und Compose-Diff bleiben auf dem Host, da insbesondere bestehende Compose-Dateien Geheimnisse enthalten können.
Nach `checked`, `updateAvailable: true` und Prüfung des Plans eine konkrete Zielversion einsetzen:
```bash
APPLY_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
TARGET_VERSION=X.Y.Z
curl --unix-socket /run/fedeo-updater/control.sock \
-H 'Content-Type: application/json' \
-d "{\"requestId\":\"$APPLY_ID\",\"version\":\"$TARGET_VERSION\",\"checkRequestId\":\"$CHECK_ID\"}" \
http://localhost/v1/apply
curl --unix-socket /run/fedeo-updater/control.sock \
"http://localhost/v1/requests/$APPLY_ID"
```
`succeeded` bestätigt den Abschluss. Der Dienst bindet den Start an genau den geprüften Release und die geprüften Konfigurationsdateien. Bei zwischenzeitlichen Änderungen bricht er mit `PLAN_CHANGED` vor Image-Pull/Wartungsphase ab. `requiresComposeAdoption: true` benötigt zunächst eine manuell geprüfte Übernahme über die CLI; die API bietet dafür keinen Freigabeschalter. Ebenso bleiben `resolve`, freie Shell-Befehle, alternative Betriebsverzeichnisse und Download-Adressen außerhalb der API. Ein zweiter Auftrag bzw. ein laufender CLI-Updater führt zu `409 BUSY`, ein offener Wiederherstellungsfall zu `409 RECOVERY_REQUIRED`.
Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem harten Dienstabbruch werden offene Aufträge als `interrupted` markiert und nicht automatisch erneut gestartet. Bei normalem Stop/Restart nimmt der Dienst keine neue Arbeit mehr an und lässt den aktiven Auftrag fertiglaufen (`KillMode=mixed`, `TimeoutStopSec=infinity`). Auch ein reguläres Herunterfahren des Hosts kann dadurch bis zum Auftragsende warten. Nach erzwungenem Abbruch oder Host-Neustart den Status prüfen; unterbrochene Updates folgen den bestehenden Wiederherstellungsprüfungen. Auftragsspezifische Fehlerdetails stehen in `.updates/service/<requestId>.log`, Werkzeugfehler weiterhin im geschützten `commands.log` des Engine-Auftrags. Über die API werden nur feste Fehlermeldungen und erlaubte Statusfelder ausgegeben, keine Rohlogs, Konfiguration, Dateipfade oder Prüffingerprints.
## Ablauf und Speicherung
1. Exklusive Host-Sperre erwerben; offene Wiederherstellungsfälle blockieren neue Updates.
2. Release samt Prüfsummen laden, Manifest/Commit prüfen und die FEDEO-Images auf die exakten Digests setzen.
3. Aktuelle und neue Compose-Konfiguration mit Docker Compose validieren. Projektname, Container-Identitäten, Mounts, Netzwerke und Zugangsdaten erhalten. Die Shell-Umgebung überschreibt dabei keine Werte aus der Instanz-`.env`.
4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images laden, Backup-Platz erneut prüfen.
5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv.
6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt.
Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr.
7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht.
8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen.
9. Stack mit `up --wait` starten. Backend-/Frontend-Image-IDs und Healthchecks sowie öffentlich gemeldete Version und Commit prüfen. Erst dann den Release als installiert speichern.
Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `status.json` gespeichert. `events.jsonl` enthält den Verlauf, `commands.log` Fehlermeldungen der Werkzeuge. Das Verzeichnis hat Modus `0700`; Status- und Konfigurationsdateien werden mit `0600` angelegt. Backups und Werkzeugausgaben enthalten vertrauliche Daten und dürfen nicht ungefiltert an eine Oberfläche ausgeliefert werden. Backups werden nicht automatisch gelöscht.
## Fehler und Wiederherstellung
- **Download, Konfiguration oder Pull fehlgeschlagen:** Der laufende Stack bleibt bestehen.
- **Backup oder Stop fehlgeschlagen:** Der Updater versucht, die zuvor laufenden Container wieder zu starten. Gelingt das nicht, bleibt der Wiederherstellungsstatus gesetzt.
- **Ab Austausch der Dateien/Infrastruktur:** Keine automatische Rückkehr zu älteren Images. Bei einem Fehler werden Backend, Frontend und Proxy nach Möglichkeit angehalten. Das Datenbankschema oder das Speicherformat könnten bereits verändert sein.
- **Prozess hart beendet oder Host neu gestartet:** `status` erkennt einen zurückgelassenen laufenden Auftrag als `interrupted`. Eine vor Beginn der Wartung gespeicherte Wiederherstellungssperre bleibt erhalten. Abgeschlossene Teiloperationen werden nicht als erfolgreiches Update ausgegeben.
Zur Diagnose `status` und die geschützten Auftragsdateien lesen. Falls ein benannter `fedeo-update-<Auftrag>`-Migrationscontainer zurückgeblieben ist, dessen Zustand und Logs zuerst prüfen. Er kann unabhängig vom abgebrochenen Compose-Prozess weiterlaufen; erst nach geklärtem Zustand anhalten/entfernen.
Für die Rückkehr zum alten Stand in einem geplanten Wartungsfenster:
1. Alle schreibenden Dienste und den Proxy angehalten lassen; keinen neuen Update-Auftrag starten.
2. Prüfsummen aus `backup/complete.json` prüfen. Ursprüngliche Compose-Datei, `.env` und gegebenenfalls Override aus `backup/config/` wiederherstellen. `backup/containers.json` dokumentiert die ursprünglichen Image-IDs; insbesondere bewegliche Infrastruktur-Tags vor dem Start auf die gesicherten Image-Stände festlegen.
3. Falls Migrationen/Infrastrukturänderungen bereits begonnen haben: PostgreSQL und Dateispeicher aus dem **zusammengehörigen** Backup wiederherstellen. Der PostgreSQL-Dump ist für `pg_restore` geeignet; `globals.sql` enthält Rollen und Kennwörter und muss beim Wiederaufbau der Datenbank berücksichtigt werden. MinIO und Zertifikate bei gestoppten Diensten aus `files.tar` wiederherstellen. Die konkrete Rücksicherung bestehender Daten erfolgt bewusst manuell und ersetzt Daten.
4. Wiederhergestellten Stack mit dessen Compose-Dateien starten und Funktionen prüfen. Alternativ den Fehler der Zielversion beheben und diese erfolgreich starten.
5. Erst nach erfolgreicher Wiederherstellung die Sperre aufheben:
```bash
sudo python3 /opt/fedeo/scripts/selfhost-update.py resolve --version 3.0.0
```
Bei Rückkehr zum alten Matrix-Stack dessen ursprüngliche Compose-Datei aus demselben Backup verwenden. Falls nötig, das vollständige `matrix/`-Verzeichnis aus `files.tar` bei gestoppten Matrix-Diensten wiederherstellen, insbesondere den gesamten PostgreSQL-Cluster mit dem ursprünglichen Datenbank-Image. Vorhandene Container-Schichten und anonyme Volumes bleiben an den behaltenen Containern erhalten. Für jeden betroffenen Container die ursprüngliche `HostConfig.RestartPolicy` aus `backup/containers.json` wieder setzen, beispielsweise `docker update --restart=unless-stopped CONTAINER_ID`, wenn genau diese Richtlinie gespeichert wurde. Danach die zuvor laufenden Matrix-Dienste mit der ursprünglichen Compose-Konfiguration starten und prüfen. `resolve` für den alten Stand prüft auch deren Image, Laufzustand und Neustartrichtlinie. `resolve` für den Zielrelease verlangt weiterhin gestoppte Matrix-Container mit deaktiviertem automatischem Neustart. Es führt diese Schritte nicht selbst aus.
`resolve` prüft die angegebene Backend-Version, Backend-Erreichbarkeit, Frontend-Erreichbarkeit, laufende Kerndienste, passende Anwendungs-Images und das Fehlen eines zurückgebliebenen Migrationscontainers. Es stellt selbst **keine Daten wieder her**. Bei alten Entwicklungsinstallationen die tatsächlich von `/backend/version` gemeldete Entwicklungsversion angeben. Ein späteres Update kann nach einer manuellen Compose-Wiederherstellung erneut `--accept-compose-replacement` erfordern.
## Entwicklung und Tests
```bash
npm test
npm run commits:check
cd backend && node --import tsx --test tests/selfhostUpdates.test.ts
cd ../frontend && node --test tests/selfhost-updates.test.cjs
```
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.

View File

@@ -0,0 +1,595 @@
<script setup>
import "gridstack/dist/gridstack.min.css"
const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue"))
const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue"))
const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue"))
const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue"))
const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue"))
const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue"))
const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue"))
const toast = useToast()
const tempStore = useTempStore()
const gridElement = ref(null)
const grid = shallowRef(null)
const isEditMode = ref(false)
const manageCardsOpen = ref(false)
const widgets = ref([])
let gridStackClass = null
let persistTimeout = null
const isSyncingGrid = ref(false)
const DASHBOARD_WIDGETS = [
{
id: "income-expense",
title: "Einnahmen und Ausgaben",
description: "Umsatz- und Ausgabenentwicklung",
component: markRaw(DisplayIncomeAndExpenditure),
defaultLayout: { x: 0, y: 0, w: 12, h: 4 },
minW: 4,
minH: 4
},
{
id: "open-balances",
title: "Buchhaltung",
description: "Offene Rechnungen und Entwurfsstatus",
component: markRaw(DisplayOpenBalances),
defaultLayout: { x: 0, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bankaccounts",
title: "Bank",
description: "Kontostand und offene Zuordnungen",
component: markRaw(DisplayBankaccounts),
defaultLayout: { x: 4, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "projects",
title: "Projekte",
description: "Aktuelle Projektphasen",
component: markRaw(DisplayProjectsInPhases),
defaultLayout: { x: 8, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tasks",
title: "Aufgaben",
description: "Offene Aufgaben des aktuellen Nutzers",
component: markRaw(DisplayOpenTasks),
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tax-summary",
title: "USt aktuell",
description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums",
component: markRaw(DisplayTaxSummary),
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bwa-summary",
title: "BWA aktuell",
description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats",
component: markRaw(DisplayBWASummary),
defaultLayout: { x: 8, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
}
]
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
function getDefaultDashboardWidgets() {
return DASHBOARD_WIDGETS.map((definition) => ({
id: definition.id,
x: definition.defaultLayout.x,
y: definition.defaultLayout.y,
w: definition.defaultLayout.w,
h: definition.defaultLayout.h,
visible: true
}))
}
function normalizeNumber(value, fallback) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function serializeWidgets(input) {
return input.map((widget) => ({
id: widget.id,
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
visible: widget.visible
}))
}
function normalizeDashboardWidgets(storedWidgets) {
const storedById = new Map(
(Array.isArray(storedWidgets) ? storedWidgets : [])
.filter((widget) => widget?.id && widgetDefinitions[widget.id])
.map((widget) => [widget.id, widget])
)
return DASHBOARD_WIDGETS.map((definition) => {
const stored = storedById.get(definition.id) || {}
return {
id: definition.id,
x: normalizeNumber(stored.x, definition.defaultLayout.x),
y: normalizeNumber(stored.y, definition.defaultLayout.y),
w: normalizeNumber(stored.w, definition.defaultLayout.w),
h: normalizeNumber(stored.h, definition.defaultLayout.h),
visible: typeof stored.visible === "boolean" ? stored.visible : true
}
})
}
function widgetsSignature(input) {
return JSON.stringify(serializeWidgets(input))
}
function getWidgetLayout(id) {
return widgets.value.find((widget) => widget.id === id)
}
function updateWidget(id, patch) {
widgets.value = widgets.value.map((widget) => {
if (widget.id !== id) return widget
return { ...widget, ...patch }
})
}
function persistWidgets() {
tempStore.modifySettings("dashboard", {
version: 1,
widgets: serializeWidgets(widgets.value)
})
}
function schedulePersistWidgets() {
clearTimeout(persistTimeout)
persistTimeout = setTimeout(() => {
persistWidgets()
}, 120)
}
async function ensureGridStack() {
if (gridStackClass) return gridStackClass
const gridStackModule = await import("gridstack")
gridStackClass = gridStackModule.GridStack
return gridStackClass
}
function gridOptionsFor(widget) {
const definition = widgetDefinitions[widget.id]
return {
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
minW: definition.minW,
minH: definition.minH,
id: widget.id
}
}
function handleGridChange(_event, items = []) {
if (isSyncingGrid.value || !isEditMode.value) return
let hasChanges = false
items.forEach((item) => {
const widgetId = item.el?.dataset?.widgetId || item.id
if (!widgetId) return
const current = getWidgetLayout(widgetId)
if (!current) return
const nextPatch = {
x: normalizeNumber(item.x, current.x),
y: normalizeNumber(item.y, current.y),
w: normalizeNumber(item.w, current.w),
h: normalizeNumber(item.h, current.h)
}
if (current.x === nextPatch.x && current.y === nextPatch.y && current.w === nextPatch.w && current.h === nextPatch.h) {
return
}
updateWidget(widgetId, nextPatch)
hasChanges = true
})
if (hasChanges) schedulePersistWidgets()
}
function syncGridInteractivity() {
if (!grid.value) return
grid.value.enableMove(isEditMode.value)
grid.value.enableResize(isEditMode.value)
}
async function syncGridWithDom() {
if (!import.meta.client) return
await nextTick()
if (!gridElement.value) return
const GridStack = await ensureGridStack()
if (!grid.value) {
isSyncingGrid.value = true
grid.value = GridStack.init(
{
column: 12,
float: true,
margin: 16,
cellHeight: 96,
disableOneColumnMode: false,
handle: ".dashboard-widget-drag-handle",
resizable: {
handles: "all"
}
},
gridElement.value
)
grid.value.on("change", handleGridChange)
syncGridInteractivity()
isSyncingGrid.value = false
}
const visibleIds = new Set(visibleWidgets.value.map((widget) => widget.id))
isSyncingGrid.value = true
grid.value.batchUpdate()
;[...grid.value.engine.nodes].forEach((node) => {
const widgetId = node.el?.dataset?.widgetId || node.id
if (!node.el || !node.el.isConnected || !visibleIds.has(widgetId)) {
if (node.el) grid.value.removeWidget(node.el, false, false)
}
})
const domWidgets = [...gridElement.value.querySelectorAll(".grid-stack-item")]
domWidgets.forEach((element) => {
const widget = visibleWidgets.value.find((item) => item.id === element.dataset.widgetId)
if (!widget) return
if (!element.gridstackNode) {
grid.value.makeWidget(element)
}
grid.value.update(element, gridOptionsFor(widget))
})
grid.value.batchUpdate(false)
isSyncingGrid.value = false
}
function addWidget(id) {
const widget = getWidgetLayout(id)
if (!widget || widget.visible) return
updateWidget(id, { visible: true })
persistWidgets()
}
function toggleEditMode() {
isEditMode.value = !isEditMode.value
if (!isEditMode.value) manageCardsOpen.value = false
}
function removeWidget(id) {
if (visibleWidgets.value.length <= 1) {
toast.add({
title: "Letzte Karte",
description: "Mindestens eine Dashboard-Karte sollte sichtbar bleiben.",
color: "orange"
})
return
}
updateWidget(id, { visible: false })
persistWidgets()
}
function resetDashboard() {
widgets.value = getDefaultDashboardWidgets()
persistWidgets()
toast.add({
title: "Dashboard zurückgesetzt",
description: "Das Standardlayout wurde wiederhergestellt.",
color: "primary"
})
}
const visibleWidgets = computed(() =>
widgets.value
.filter((widget) => widget.visible)
.map((widget) => ({
...widgetDefinitions[widget.id],
...widget
}))
)
const hiddenWidgets = computed(() =>
DASHBOARD_WIDGETS.filter((definition) => !getWidgetLayout(definition.id)?.visible)
)
watch(
() => tempStore.settings?.dashboard,
(storedDashboard) => {
const nextWidgets = normalizeDashboardWidgets(storedDashboard?.widgets)
if (widgetsSignature(nextWidgets) === widgetsSignature(widgets.value)) return
widgets.value = nextWidgets
},
{ immediate: true, deep: true }
)
watch(
visibleWidgets,
async () => {
await syncGridWithDom()
},
{ deep: true }
)
watch(isEditMode, () => {
syncGridInteractivity()
})
onMounted(async () => {
if (!widgets.value.length) widgets.value = normalizeDashboardWidgets()
await syncGridWithDom()
})
onBeforeUnmount(() => {
clearTimeout(persistTimeout)
if (grid.value) {
grid.value.off("change", handleGridChange)
grid.value.destroy(false)
grid.value = null
}
})
</script>
<template>
<UDashboardNavbar title="Home">
<template #right>
<div class="flex items-center gap-2">
<UButton
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'"
:color="isEditMode ? 'primary' : 'gray'"
:variant="isEditMode ? 'solid' : 'ghost'"
@click="toggleEditMode"
>
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }}
</UButton>
<UButton
v-if="isEditMode && hiddenWidgets.length > 0"
icon="i-heroicons-plus"
color="white"
variant="soft"
@click="manageCardsOpen = true"
>
Karte hinzufügen
</UButton>
<UButton
v-if="isEditMode"
icon="i-heroicons-arrow-path"
color="gray"
variant="ghost"
@click="resetDashboard"
>
Standardlayout
</UButton>
<UButton
v-if="isEditMode"
icon="i-heroicons-squares-2x2"
color="gray"
variant="ghost"
@click="manageCardsOpen = true"
>
Karten verwalten
</UButton>
</div>
</template>
</UDashboardNavbar>
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto">
<div
v-for="widget in visibleWidgets"
:key="widget.id"
:class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']"
:data-widget-id="widget.id"
:gs-x="widget.x"
:gs-y="widget.y"
:gs-w="widget.w"
:gs-h="widget.h"
:gs-min-w="widget.minW"
:gs-min-h="widget.minH"
>
<div class="grid-stack-item-content dashboard-grid-item">
<div class="dashboard-widget-card border border-gray-200 dark:border-gray-800">
<div class="dashboard-widget-header border-b border-gray-200 dark:border-gray-800">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div :class="['dashboard-widget-drag-handle font-semibold', isEditMode ? 'cursor-move' : 'cursor-default']">
{{ widget.title }}
</div>
<p class="mt-1 text-sm">
{{ widget.description }}
</p>
</div>
<div class="dashboard-widget-header-actions">
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
<UButtonGroup v-if="isEditMode" size="xs">
<UButton
color="gray"
variant="soft"
icon="i-heroicons-arrows-pointing-out"
class="dashboard-widget-drag-handle"
/>
<UButton
color="gray"
variant="soft"
icon="i-heroicons-x-mark"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(widget.id)"
/>
</UButtonGroup>
</div>
</div>
</div>
<div class="dashboard-widget-body">
<component
:is="widget.component"
v-bind="widget.id === 'income-expense'
? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` }
: {}"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
<p class="text-sm">
Es sind aktuell keine Dashboard-Karten sichtbar.
</p>
<UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true">
Karte hinzufügen
</UButton>
</div>
<UModal v-model:open="manageCardsOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold">Dashboard-Karten</h2>
<p class="text-sm">
Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen.
</p>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
Zurücksetzen
</UButton>
</div>
</template>
<div class="space-y-3">
<div
v-for="definition in DASHBOARD_WIDGETS"
:key="definition.id"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3"
>
<div>
<p class="font-medium">{{ definition.title }}</p>
<p class="text-sm">{{ definition.description }}</p>
</div>
<UButton
v-if="getWidgetLayout(definition.id)?.visible"
color="gray"
variant="soft"
icon="i-heroicons-minus"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(definition.id)"
>
Entfernen
</UButton>
<UButton
v-else
color="primary"
variant="soft"
icon="i-heroicons-plus"
@click="addWidget(definition.id)"
>
Hinzufügen
</UButton>
</div>
</div>
</UCard>
</template>
</UModal>
</template>
<style scoped>
.dashboard-grid {
min-height: 60vh;
}
.dashboard-grid-item {
height: 100%;
padding: 0.25rem;
}
.dashboard-widget-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.dashboard-widget-header {
flex: 0 0 auto;
padding: 1rem 1rem 0.875rem;
}
.dashboard-widget-header-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-end;
gap: 0.5rem;
}
.dashboard-widget-header-target {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.dashboard-widget-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 1rem;
}
:deep(.grid-stack-item-content) {
inset: 0;
background: transparent;
overflow-y: hidden !important;
}
:deep(.grid-stack-item:not(.dashboard-widget-editing) .ui-resizable-handle) {
display: none;
}
</style>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
const { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending } = useSelfhostUpdates()
const build = useRuntimeConfig().public
const confirmOpen = ref(false)
const confirmed = ref(false)
const phaseLabels: Record<string, string> = {
preparing: 'Update vorbereiten', validating: 'Release und Konfiguration prüfen', checked: 'Prüfung abgeschlossen',
pulling: 'Images herunterladen', maintenance: 'Dienste anhalten und Backup erstellen', backed_up: 'Backup abgeschlossen',
applying: 'Compose und Konfiguration aktualisieren', retiring_matrix: 'Alte Matrix-Dienste stilllegen',
migrating: 'Datenbank migrieren', starting: 'Dienste starten', verifying: 'Version und Erreichbarkeit prüfen',
complete: 'Abgeschlossen', resolved: 'Wiederherstellung bestätigt',
}
const statusLabels: Record<string, string> = {
queued: 'Auftrag angenommen', running: 'Auftrag läuft', checked: 'Update verfügbar', up_to_date: 'FEDEO ist aktuell',
succeeded: 'Update erfolgreich abgeschlossen', failed: 'Auftrag fehlgeschlagen', interrupted: 'Auftrag unterbrochen',
idle: 'Bereit', recovered: 'Wiederherstellung bestätigt', unknown: 'Auftragsstatus wird geladen',
}
const progress = computed(() => job.value?.progress || (busy.value ? overview.value?.engine : null))
const statusLabel = computed(() => phaseLabels[progress.value?.phase || ''] || statusLabels[job.value?.status || 'idle'])
const backupSize = computed(() => {
const bytes = job.value?.plan?.estimatedBackupBytes
return bytes === undefined ? 'Wird vor dem Start geprüft' : `${(bytes / 1024 ** 3).toLocaleString('de-DE', { maximumFractionDigits: 1 })} GiB`
})
const openConfirmation = () => { confirmed.value = false; confirmOpen.value = true }
const start = async () => {
if (!confirmed.value || !canApply.value) return
confirmOpen.value = false
await apply()
}
const reload = () => window.location.reload()
</script>
<template>
<UCard :ui="{ root: 'rounded-lg' }">
<template #header>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2">
<UIcon name="i-heroicons-arrow-down-tray" class="size-5 text-primary" />
<h2 class="text-base font-semibold text-highlighted">FEDEO-Updates</h2>
</div>
<UBadge color="neutral" variant="soft">Installiert: {{ overview?.backend.version || build.fedeoVersion }}</UBadge>
</div>
</template>
<div class="space-y-4">
<p class="text-sm text-muted">Aktualisiert die gesamte Selfhost-Instanz einschließlich Docker Compose und Datenbank. Vor Änderungen wird ein Backup erstellt.</p>
<div role="status" aria-live="polite" class="space-y-3">
<UAlert v-if="message" :color="busy ? 'warning' : 'neutral'" variant="soft" :title="message" />
<UAlert v-if="recovery" color="error" title="Wiederherstellung auf dem Host erforderlich" description="Weitere Updates sind gesperrt. Den letzten Auftrag und die Backups auf dem Host prüfen." />
<UAlert v-if="job?.error" color="error" :title="job.error.message" />
<UAlert v-if="job?.plan?.requiresComposeAdoption" color="warning" title="Compose-Datei zuerst auf dem Host übernehmen" description="Die bestehende Konfiguration benötigt eine manuelle Prüfung mit dem CLI-Updater. Anschließend hier erneut nach Updates suchen." />
<p v-if="job || busy" class="text-sm font-medium">{{ statusLabel }}</p>
<UProgress v-if="busy && !pending" aria-label="Update-Fortschritt" />
</div>
<dl v-if="job?.plan" class="grid gap-3 text-sm sm:grid-cols-3">
<div><dt class="text-muted">Geprüfte Version</dt><dd class="font-medium">{{ job.plan.version }}</dd></div>
<div><dt class="text-muted">Commit</dt><dd class="font-mono" :title="job.plan.revision">{{ job.plan.revision?.slice(0, 8) }}</dd></div>
<div><dt class="text-muted">Geschätztes Backup</dt><dd>{{ backupSize }}</dd></div>
</dl>
<p v-if="job?.plan?.retiredServices.length" class="text-sm text-muted">Alte Matrix-Dienste werden stillgelegt; Daten und Container bleiben erhalten: {{ job.plan.retiredServices.join(', ') }}.</p>
<p v-if="job?.requestId" class="break-all text-xs text-muted">Auftrag: {{ job.requestId }}</p>
<div class="flex flex-wrap gap-2">
<UButton icon="i-heroicons-magnifying-glass" :disabled="!available" :loading="submitting && pending?.command === 'check'" @click="check">Nach Updates suchen</UButton>
<UButton v-if="canApply" icon="i-heroicons-arrow-down-tray" @click="openConfirmation">Update auf {{ job?.plan?.version }} installieren</UButton>
<UButton v-if="pending && connected && !accessDenied" color="warning" :loading="submitting" @click="sendPending">Auftrag erneut senden</UButton>
<UButton v-if="!connected && !accessDenied" color="neutral" variant="outline" :loading="refreshing" @click="refresh">Verbindung prüfen</UButton>
<UButton v-if="job?.command === 'apply' && job.status === 'succeeded'" color="success" @click="reload">Neue Version laden</UButton>
</div>
</div>
</UCard>
<UModal v-model:open="confirmOpen" title="FEDEO-Update installieren" description="Dieses Update betrifft die gesamte Instanz und alle Mandanten.">
<template #body>
<div class="space-y-4 text-sm">
<p>Version <strong>{{ job?.plan?.version }}</strong> wird installiert. Dabei werden die Dienste vorübergehend angehalten, Daten gesichert und Datenbankmigrationen ausgeführt.</p>
<p>Die Verbindung kann währenddessen abbrechen. Der Host setzt den Auftrag fort; diese Seite fragt den Status automatisch wieder ab.</p>
<p>Falls die Migration fehlschlägt, kann eine manuelle Wiederherstellung auf dem Host erforderlich sein.</p>
<UCheckbox v-model="confirmed" label="Die Wartungsunterbrechung für alle Mandanten ist abgestimmt." />
</div>
</template>
<template #footer>
<div class="flex flex-wrap justify-end gap-2">
<UButton color="neutral" variant="outline" @click="confirmOpen = false">Abbrechen</UButton>
<UButton :disabled="!confirmed || !canApply" :loading="submitting" @click="start">Jetzt installieren</UButton>
</div>
</template>
</UModal>
</template>

View File

@@ -1,5 +1,6 @@
<script setup>
import dayjs from "dayjs"
import { getBankAllocationAmounts, isDirectBankAllocation } from "~/utils/bankAllocationTax"
const props = defineProps({
item: {
@@ -12,6 +13,8 @@ const loading = ref(true)
const incomingInvoices = ref([])
const createddocuments = ref([])
const costcentres = ref([])
const bankAllocations = ref([])
const accounts = ref([])
const selectedYear = ref(String(dayjs().year()))
const selectedMonth = ref("all")
@@ -74,8 +77,10 @@ const relevantCostCentreIds = computed(() => {
const yearItems = computed(() => {
const years = [...new Set(
incomingInvoices.value
.map((invoice) => invoice.date ? String(dayjs(invoice.date).year()) : null)
[...incomingInvoices.value.map(invoice => invoice.date),
...createddocuments.value.map(document => document.documentDate),
...bankAllocations.value.map(allocation => allocation.bankstatement?.date)]
.map((date) => date ? String(dayjs(date).year()) : null)
.filter(Boolean)
)].sort((a, b) => Number(b) - Number(a))
@@ -183,7 +188,33 @@ const reportRows = computed(() => {
.filter(Boolean)
})
return [...incomingRows, ...outgoingRows]
const bankRows = bankAllocations.value.flatMap((allocation) => {
const statement = allocation.bankstatement
const date = dayjs(statement.date || statement.valueDate)
if (!date.isValid() || String(date.year()) !== selectedYear.value
|| (selectedMonth.value !== "all" && date.month() + 1 !== Number(selectedMonth.value))) return []
const centre = costCentreMap.value.get(getCostCentreId(allocation.costcentre))
const account = accounts.value.find(account => account.id === (allocation.account?.id || allocation.account)) || allocation.ownaccount
const amounts = getBankAllocationAmounts(allocation)
// As for incoming documents, expenses are positive and refunds negative.
const sign = amounts.kind === "input" || (amounts.kind === "none" && amounts.amountGross < 0) ? -1 : 1
return [{
id: `bank-${allocation.id}`,
sourceLabel: "Bankbuchung",
statementId: statement.id,
reference: `Bank #${statement.id}`,
date: statement.date || statement.valueDate,
state: "Gebucht",
vendorName: statement.debName || statement.credName || "-",
accountLabel: `${account?.number || ""} ${account?.label || account?.name || ""}`.trim() || "-",
costCentreName: centre ? `${centre.number} - ${centre.name}` : "-",
description: allocation.description || statement.text || "-",
amountNet: amounts.amountNet * sign,
amountTax: amounts.amountTax * sign,
amountGross: amounts.amountGross * sign,
}]
})
return [...incomingRows, ...outgoingRows, ...bankRows]
})
const totals = computed(() => {
@@ -212,8 +243,12 @@ const setupPage = async () => {
loading.value = true
costcentres.value = await useEntities("costcentres").select("*", null, false, true)
accounts.value = await useEntities("accounts").selectSpecial()
const invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
const documents = await useEntities("createddocuments").select("*, customer(id,name)")
const allocations = await useEntities("statementallocations").select("*, bankstatement(*), account(*), ownaccount(*)")
bankAllocations.value = allocations.filter(allocation => isDirectBankAllocation(allocation)
&& relevantCostCentreIds.value.has(getCostCentreId(allocation.costcentre)))
incomingInvoices.value = invoices.filter((invoice) =>
(invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre))
@@ -283,7 +318,10 @@ setupPage()
class="w-full"
>
<template #reference-cell="{ row }">
<div class="truncate font-medium">{{ row.original.reference }}</div>
<NuxtLink v-if="row.original.statementId" :to="`/banking/statements/show/${row.original.statementId}`" class="font-medium text-primary">
{{ row.original.reference }}
</NuxtLink>
<div v-else class="truncate font-medium">{{ row.original.reference }}</div>
</template>
<template #date-cell="{ row }">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import {
getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown
@@ -125,6 +126,12 @@ const loadSummary = async () => {
return sum + breakdown.tax19 + breakdown.tax7
}, 0)
const bankTaxBalance = (allocations || []).reduce((sum: number, allocation: any) => {
const date = dayjs(allocation.bankstatement?.date || allocation.bankstatement?.valueDate)
if (!date.isValid() || date.isBefore(bounds.start, "day") || date.isAfter(bounds.end, "day")) return sum
const { output, input } = getBankAllocationTaxBreakdown(allocation)
return sum + output.tax19 + output.tax7 - input.tax19 - input.tax7
}, 0)
const expenses = invoiceExpenses + directAccountExpenses + depreciations
summary.value = {
@@ -133,7 +140,7 @@ const loadSummary = async () => {
expenses: Number(expenses.toFixed(2)),
depreciations: Number(depreciations.toFixed(2)),
result: Number((income - Math.abs(expenses)).toFixed(2)),
taxBalance: Number((outputTax - inputTax).toFixed(2)),
taxBalance: Number((outputTax - inputTax + bankTaxBalance).toFixed(2)),
incomeCount: outputDocs.length,
expenseCount: inputDocs.filter((invoice: any) => getIncomingInvoiceImmediateExpenseNet(invoice) > 0).length + directAccountAllocations.filter((allocation: any) => getStatementAllocationImmediateExpenseAmount(allocation) > 0).length,
depreciationCount: depreciationRows.length

View File

@@ -1,3 +1,4 @@
import { getBankAllocationAmounts } from "../utils/bankAllocationTax"
import dayjs from "dayjs"
export const EXPENSE_BOOKING_MODE_ITEMS = [
@@ -308,7 +309,7 @@ export const getStatementAllocationImmediateExpenseImpact = (allocation: any) =>
if (allocation?.account === null || allocation?.account === undefined) return 0
const mode = normalizeExpenseBookingMode(allocation?.bookingMode)
const amount = Number(allocation?.amount || 0)
const amount = getBankAllocationAmounts(allocation).amountNet
if (mode !== "expense") return 0
return Number((-amount).toFixed(2))
}
@@ -317,7 +318,7 @@ export const getStatementAllocationImmediateExpenseAmount = (allocation: any) =>
return Math.max(0, getStatementAllocationImmediateExpenseImpact(allocation))
}
export const getStatementAccountExpenseTotal = (allocations: any[] = []) => {
export const getStatementAccountExpenseTotal = (allocations: any[] = [], gross = false) => {
const balances = new Map<string, number>()
const addAmount = (account: any, amount: number) => {
const id = account?.id || account
@@ -330,7 +331,8 @@ export const getStatementAccountExpenseTotal = (allocations: any[] = []) => {
;(allocations || []).forEach((allocation) => {
if (normalizeExpenseBookingMode(allocation?.bookingMode) !== "expense") return
const amount = Number(allocation?.amount || 0)
const amounts = getBankAllocationAmounts(allocation)
const amount = gross ? amounts.amountGross : amounts.amountNet
addAmount(allocation?.account, amount)
addAmount(allocation?.contraAccount, -amount)
})
@@ -344,7 +346,7 @@ export const getStatementAccountExpenseTotal = (allocations: any[] = []) => {
export const getStatementAllocationDepreciationAmount = (allocation: any, rangeStart: any, rangeEnd: any) => {
const mode = normalizeExpenseBookingMode(allocation?.bookingMode)
const rawAmount = Number(allocation?.amount || 0)
const rawAmount = getBankAllocationAmounts(allocation).amountNet
const amount = Math.abs(rawAmount)
if (mode === "expense" || rawAmount >= 0 || amount <= 0) return 0
@@ -360,7 +362,7 @@ export const getStatementAllocationDepreciationAmount = (allocation: any, rangeS
}
export const getStatementAllocationDepreciationRow = (allocation: any, rangeStart: any, rangeEnd: any) => {
const totalAmount = Math.abs(Number(allocation?.amount || 0))
const totalAmount = Math.abs(getBankAllocationAmounts(allocation).amountNet)
const months = Number(allocation?.depreciationMonths || 0)
const startDate = allocation?.depreciationStartDate || allocation?.created_at
const method = normalizeDepreciationMethod(allocation?.depreciationMethod)

View File

@@ -0,0 +1,149 @@
export type UpdateState = {
status: string; requestId?: string; command?: 'check' | 'apply'; phase?: string;
version?: string; revision?: string; recoveryRequired?: boolean; updatedAt?: string;
}
export type UpdateJob = UpdateState & {
progress?: UpdateState; error?: { code: string; message: string };
plan?: { version: string; revision: string; previousVersion?: string; updateAvailable: boolean;
requiresComposeAdoption: boolean; estimatedBackupBytes?: number; retiredServices: string[] };
}
type Overview = {
acceptingRequests: boolean; engine: UpdateState; request?: UpdateJob;
installed: { version?: string }; backend: { version: string; revision: string };
}
type Submission = { command: 'check' | 'apply'; body: { requestId: string; version?: string; checkRequestId?: string } }
export const useSelfhostUpdates = () => {
const { $api } = useNuxtApp()
const auth = useAuthStore()
const overview = ref<Overview | null>(null)
const job = ref<UpdateJob | null>(null)
const pending = ref<Submission | null>(null)
const connected = ref(false)
const submitting = ref(false)
const refreshing = ref(false)
const message = ref('')
const accessDenied = ref(false)
let stopped = false
let generation = 0
let timer: ReturnType<typeof setTimeout> | undefined
const storageKey = `fedeo:update:${auth.user?.user_id || auth.user?.id || auth.user?.email}`
const active = (value?: UpdateState | null) => ['queued', 'running'].includes(value?.status || '')
const recovery = computed(() => !!overview.value?.engine.recoveryRequired && !active(overview.value?.engine))
const busy = computed(() => submitting.value || !!pending.value || active(job.value) || active(overview.value?.engine))
const available = computed(() => connected.value && !accessDenied.value && overview.value?.acceptingRequests && !busy.value && !recovery.value)
const canApply = computed(() => !!(available.value && job.value?.command === 'check' && job.value.status === 'checked'
&& job.value.plan?.updateAvailable && !job.value.plan.requiresComposeAdoption))
const persist = () => {
// Losing localStorage must not lose the durable host job; status also returns the latest request.
try { localStorage.setItem(storageKey, JSON.stringify({ requestId: job.value?.requestId, pending: pending.value })) } catch {}
}
const api = <T>(path: string, options = {}) => $api<T>(`/api/admin/updates/${path}`, {
retry: 0, timeout: 8000, onRequestError: () => {}, onResponseError: () => {}, ...options,
})
const errorStatus = (error: any) => error?.statusCode || error?.response?.status
const report = (error: any) => {
if ([401, 403].includes(errorStatus(error))) {
accessDenied.value = true
message.value = 'Bitte erneut als Systemadministrator anmelden.'
} else if (pending.value) {
message.value = 'Der Start ist noch nicht bestätigt. Der Auftragsstatus wird weiter geprüft.'
} else if (job.value?.command === 'apply' && active(job.value)) {
message.value = 'Verbindung unterbrochen. Der Host führt das Update weiter aus; die Verbindung wird automatisch erneut geprüft.'
} else {
message.value = error?.data?.error?.message || 'Der Update-Dienst ist nicht erreichbar. Dienst und Socket-Einbindung auf dem Host prüfen.'
}
}
const refresh = async () => {
if (refreshing.value || stopped || accessDenied.value) return
refreshing.value = true
const currentGeneration = generation
try {
const latest = await api<Overview>('status')
if (currentGeneration !== generation || stopped) return
overview.value = latest
// A durable submission may have succeeded even when its HTTP response was lost.
const ownId = pending.value?.body.requestId || job.value?.requestId
if (ownId) {
try {
const result = await api<UpdateJob>(`requests/${encodeURIComponent(ownId)}`)
if (currentGeneration !== generation || stopped) return
job.value = result
pending.value = null
} catch (error) {
if (currentGeneration !== generation || stopped) return
if (errorStatus(error) !== 404) throw error
if (!pending.value) job.value = null
}
}
if (!pending.value && latest.request && (active(latest.request) || !job.value)) job.value = latest.request
connected.value = true
message.value = pending.value ? 'Start noch nicht bestätigt. Du kannst denselben Auftrag erneut senden.' : ''
persist()
} catch (error) {
if (currentGeneration !== generation || stopped) return
connected.value = false
report(error)
} finally { refreshing.value = false }
}
const sendPending = async () => {
if (!pending.value || submitting.value || accessDenied.value) return
generation++
submitting.value = true
const submission = pending.value
try {
job.value = await api<UpdateJob>(submission.command, { method: 'POST', body: submission.body })
pending.value = null
connected.value = true
message.value = ''
} catch (error: any) {
// Only a definite rejection releases the ID. Timeouts/5xx keep it for idempotent recovery.
if ([400, 401, 403, 404, 409, 413, 415].includes(errorStatus(error))) {
pending.value = null
job.value = null
message.value = error?.data?.error?.message || 'Der Auftrag wurde abgelehnt. Bitte erneut prüfen.'
if ([401, 403].includes(errorStatus(error))) report(error)
} else {
connected.value = false
report(error)
}
} finally { submitting.value = false; persist() }
}
const check = async () => {
if (!available.value) return
pending.value = { command: 'check', body: { requestId: crypto.randomUUID() } }
job.value = null
persist()
await sendPending()
}
const apply = async () => {
if (!canApply.value) return
pending.value = { command: 'apply', body: {
requestId: crypto.randomUUID(), version: job.value!.plan!.version, checkRequestId: job.value!.requestId!,
} }
persist()
await sendPending()
}
const poll = async () => {
await refresh()
if (!stopped && !accessDenied.value) timer = setTimeout(poll, busy.value || !connected.value ? 3000 : 15000)
}
onMounted(() => {
try {
const saved = JSON.parse(localStorage.getItem(storageKey) || 'null')
const validId = (value: unknown) => typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{7,79}$/.test(value)
if (validId(saved?.requestId)) job.value = { status: 'unknown', requestId: saved.requestId }
const p = saved?.pending
if (p && ['check', 'apply'].includes(p.command) && validId(p.body?.requestId)
&& (p.command === 'check' || (validId(p.body?.checkRequestId) && /^\d+\.\d+\.\d+$/.test(p.body?.version)))) {
pending.value = { command: p.command, body: { requestId: p.body.requestId,
...(p.command === 'apply' ? { version: p.body.version, checkRequestId: p.body.checkRequestId } : {}) } }
}
} catch {}
void poll()
})
onBeforeUnmount(() => { stopped = true; clearTimeout(timer) })
return { overview, job, pending, connected, submitting, refreshing, message, accessDenied, recovery, busy, available, canApply,
check, apply, refresh, sendPending }
}

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import dayjs from "dayjs"
import { getBankAllocationAmounts, getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import {
getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown
@@ -201,7 +202,7 @@ const expenseNetTotal = computed(() => {
const expenseGrossTotal = computed(() => {
const invoiceExpenses = filteredIncomingInvoices.value.reduce((sum, invoice) => sum + getIncomingInvoiceImmediateExpenseGross(invoice), 0)
const directAccountExpenses = getStatementAccountExpenseTotal(filteredStatementAllocations.value)
const directAccountExpenses = getStatementAccountExpenseTotal(filteredStatementAllocations.value, true)
const depreciations = depreciationTotal.value
@@ -231,6 +232,13 @@ const taxSummary = computed(() => {
}
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
filteredStatementAllocations.value.forEach((allocation) => {
const breakdown = getBankAllocationTaxBreakdown(allocation)
for (const key of ["net19", "tax19", "net7", "tax7", "net0"] as const) {
output[key] += breakdown.output[key]
input[key] += breakdown.input[key]
}
})
const outputTax = Number((output.tax19 + output.tax7).toFixed(2))
const inputTax = Number((input.tax19 + input.tax7).toFixed(2))
@@ -303,13 +311,9 @@ const accountRows = computed(() => {
const debitBookings = filteredStatementAllocations.value
.filter((allocation) => sameId(allocation.account?.id || allocation.account, account.id))
.map((allocation) => {
const amount = Number(allocation.amount || 0)
return {
type: "statementallocation",
amountNet: amount,
amountTax: 0,
amountGross: amount
...getBankAllocationAmounts(allocation)
}
})

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { getBankAllocationAmounts } from "~/utils/bankAllocationTax"
import dayjs from "dayjs"
import {
DEPRECIATION_METHOD_ITEMS,
@@ -114,7 +115,7 @@ const depreciationAssets = computed(() => {
if (!isDepreciationBookingMode(allocation?.bookingMode)) return null
const status = getAssetDepreciationStatus({
amount: Math.abs(Number(allocation.amount || 0)),
amount: Math.abs(getBankAllocationAmounts(allocation).amountNet),
depreciationMonths: allocation.depreciationMonths,
depreciationStartDate: allocation.depreciationStartDate || allocation.created_at,
depreciationMethod: allocation.depreciationMethod,
@@ -135,7 +136,7 @@ const depreciationAssets = computed(() => {
months: Number(allocation.depreciationMonths || 0),
startDate: allocation.depreciationStartDate || allocation.created_at,
residualValue: Number(allocation.residualValue || 0),
originalValue: Math.abs(Number(allocation.amount || 0)),
originalValue: Math.abs(getBankAllocationAmounts(allocation).amountNet),
currentPeriodAmount: Number(currentPeriodRow?.amount || 0),
depreciated: status.depreciated,
remaining: status.remaining,

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown, isDirectBankAllocation } from "~/utils/bankAllocationTax"
import customParseFormat from "dayjs/plugin/customParseFormat"
import {
formatTaxEvaluationPeriodLabel,
@@ -20,6 +21,7 @@ const loading = ref(true)
const createdDocuments = ref<any[]>([])
const incomingInvoices = ref<any[]>([])
const manualBookings = ref<any[]>([])
const bankAllocations = ref<any[]>([])
const periodType = computed(() => normalizeTaxEvaluationPeriod(auth.activeTenantData?.taxEvaluationPeriod))
@@ -42,15 +44,17 @@ const loadData = async () => {
loading.value = true
try {
const [docs, incoming, manual] = await Promise.all([
const [docs, incoming, manual, allocations] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select(),
useNuxtApp().$api("/api/banking/manual-bookings")
useNuxtApp().$api("/api/banking/manual-bookings"),
useEntities("statementallocations").select("*, bankstatement(*)")
])
createdDocuments.value = (docs || []).filter(isRelevantOutputDocument)
incomingInvoices.value = (incoming || []).filter(isRelevantInputInvoice)
manualBookings.value = (manual as any[]) || []
bankAllocations.value = (allocations || []).filter(isDirectBankAllocation)
} finally {
loading.value = false
}
@@ -108,6 +112,18 @@ const periods = computed(() => {
}
}, { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 })
const directBookings = bankAllocations.value.filter((allocation) => {
const date = dayjs(allocation.bankstatement?.date || allocation.bankstatement?.valueDate)
return date.isValid() && !date.isBefore(bounds.start, "day") && !date.isAfter(bounds.end, "day")
})
directBookings.forEach((allocation) => {
const breakdown = getBankAllocationTaxBreakdown(allocation)
for (const key of ["net19", "tax19", "net7", "tax7", "net0"] as const) {
output[key] += breakdown.output[key]
input[key] += breakdown.input[key]
}
})
output.tax19 = Number((output.tax19 + manualTax.outputTax19).toFixed(2))
input.tax19 = Number((input.tax19 + manualTax.inputTax19).toFixed(2))
@@ -127,6 +143,7 @@ const periods = computed(() => {
input,
outputCount: outputDocs.length,
inputCount: inputDocs.length,
bankCount: directBookings.length,
}
})
})
@@ -136,7 +153,7 @@ const currentPeriod = computed(() => periods.value[0] || null)
const columns = [
{ key: "label", label: "Zeitraum" },
{ key: "range", label: "Datumsbereich" },
{ key: "outputTax", label: "USt Rechnungen" },
{ key: "outputTax", label: "Umsatzsteuer" },
{ key: "inputTax", label: "Vorsteuer" },
{ key: "balance", label: "Ergebnis" },
{ key: "documents", label: "Belege" },
@@ -166,7 +183,8 @@ onMounted(loadData)
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">
Intervall: {{ periodType === "monthly" ? "monatlich" : periodType === "quarterly" ? "quartalsweise" : "jährlich" }}.
Berücksichtigt werden gebuchte Ausgangsrechnungen, Abschlags- und Stornorechnungen, gebuchte Eingangsbelege sowie manuelle Buchungen auf SKR03 1576 und 1776.
Berücksichtigt werden gebuchte Ausgangsrechnungen, Abschlags- und Stornorechnungen, gebuchte Eingangsbelege,
direkte Bankbuchungen mit Umsatzsteuer oder Vorsteuer sowie manuelle Buchungen auf SKR03 1576 und 1776.
</p>
<p v-if="currentPeriod" class="text-sm text-gray-500 dark:text-gray-400">
{{ currentPeriod.range }}
@@ -203,7 +221,7 @@ onMounted(loadData)
{{ formatCurrency(currentPeriod.balance) }}
</div>
<div class="mt-3 text-sm text-gray-500 dark:text-gray-400">
{{ currentPeriod.outputCount }} Ausgangsbelege | {{ currentPeriod.inputCount }} Eingangsbelege
{{ currentPeriod.outputCount }} Ausgangsbelege | {{ currentPeriod.inputCount }} Eingangsbelege | {{ currentPeriod.bankCount }} direkte Bankbuchungen
</div>
</UCard>
</div>
@@ -211,7 +229,7 @@ onMounted(loadData)
<div v-if="currentPeriod" class="mt-6 grid gap-4 xl:grid-cols-2">
<UCard>
<template #header>
<div class="font-semibold">Ausgangsrechnungen</div>
<div class="font-semibold">Umsatzsteuer aus Belegen und Bankbuchungen</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
@@ -239,7 +257,7 @@ onMounted(loadData)
<UCard>
<template #header>
<div class="font-semibold">Eingangsbelege</div>
<div class="font-semibold">Vorsteuer aus Belegen und Bankbuchungen</div>
</template>
<div class="space-y-3 text-sm">
<div class="flex items-center justify-between">
@@ -298,7 +316,7 @@ onMounted(loadData)
</template>
<template #documents-cell="{ row }">
{{ row.original.outputCount }} / {{ row.original.inputCount }}
{{ row.original.outputCount }} / {{ row.original.inputCount }} · {{ row.original.bankCount }} Bankbuchungen
</template>
<template #empty>
<TableEmptyState label="Keine Daten für die USt-Auswertung vorhanden" icon="i-heroicons-calculator" />

View File

@@ -96,6 +96,7 @@ onMounted(async () => {
<UDashboardPanelContent>
<div class="space-y-6">
<SelfhostUpdateCard v-if="auth.user?.is_admin" />
<UAlert
:icon="overallStatus === 'ok' ? 'i-heroicons-check-circle' : 'i-heroicons-exclamation-triangle'"
:color="overallStatus === 'ok' ? 'success' : 'warning'"

View File

@@ -1,5 +1,6 @@
<script setup>
import dayjs from "dayjs";
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
import {
DEPRECIATION_METHOD_ITEMS,
EXPENSE_BOOKING_MODE_ITEMS,
@@ -36,6 +37,7 @@ const createddocuments = ref([])
const incominginvoices = ref([])
const accounts = ref([])
const ownaccounts = ref([])
const costcentres = ref([])
const loading = ref(true)
const loadingDocuments = ref(true)
@@ -76,15 +78,17 @@ const setup = async () => {
useEntities("vendors").select()
])
const [statement, accountItems] = await Promise.all([
const [statement, accountItems, costCentreItems] = await Promise.all([
route.params.id
? useEntities("bankstatements").selectSingle(route.params.id, "*, statementallocations(*, cd_id(*), ii_id(*))")
: Promise.resolve(itemInfo.value),
useEntities("accounts").selectSpecial("*", "number", true)
useEntities("accounts").selectSpecial("*", "number", true),
useEntities("costcentres").select()
])
itemInfo.value = statement
accounts.value = accountItems
costcentres.value = costCentreItems
if (itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
manualAllocationSum.value = calculateOpenSum.value
@@ -164,6 +168,21 @@ const selectAccount = (id) => {
const manualAllocationSum = ref(itemInfo.value.amount || 0)
const allocationDescription = ref("")
const allocationCostcentre = ref(null)
const allocationTaxKey = ref(null)
const allocationAmounts = computed(() => getBankAllocationAmounts({
bankstatement: itemInfo.value.id,
account: accountToSave.value,
ownaccount: ownAccountToSave.value,
amount: manualAllocationSum.value,
datevTaxKey: allocationTaxKey.value,
}))
const getCostCentreLabel = (id) => {
const centre = costcentres.value.find(item => item.id === id)
return centre ? `${centre.number} - ${centre.name}` : id
}
const getAllocationTaxLabel = (item) => BANK_ALLOCATION_TAX_ITEMS.find(tax => tax.value === item.datevTaxKey)?.label
|| (item.datevTaxKey ? `Steuerschlüssel ${item.datevTaxKey}` : "")
const allocationBookingMode = ref("expense")
const allocationDepreciationMonths = ref(36)
const allocationDepreciationStartDate = ref(dayjs().format("YYYY-MM-DD"))
@@ -179,6 +198,10 @@ const isDepreciationAllocation = computed(() => isDepreciationBookingMode(alloca
const buildAllocationPayload = (payload) => {
const base = {
...payload,
...((payload.account || payload.ownaccount) ? {
costcentre: allocationCostcentre.value || null,
datevTaxKey: allocationTaxKey.value || null,
} : {}),
bookingMode: normalizeExpenseBookingMode(allocationBookingMode.value)
}
@@ -232,6 +255,8 @@ const saveAllocation = async (allocation) => {
vendorAccountToSave.value = null
customerAccountToSave.value = null
ownAccountToSave.value = null
allocationCostcentre.value = null
allocationTaxKey.value = null
allocationBookingMode.value = "expense"
allocationDepreciationMonths.value = 36
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
@@ -696,6 +721,11 @@ setup()
{{ getAllocationLabel(item) }}
</div>
<div class="text-xs text-gray-500 mt-0.5" v-if="item.description">{{ item.description }}</div>
<div v-if="item.costcentre" class="text-xs text-gray-500 mt-1">Kostenstelle: {{ getCostCentreLabel(item.costcentre) }}</div>
<div v-if="item.datevTaxKey" class="text-xs text-gray-500 mt-1">
{{ getAllocationTaxLabel(item) }} · Netto {{ displayCurrency(getBankAllocationAmounts(item).amountNet) }}
· Steuer {{ displayCurrency(getBankAllocationAmounts(item).amountTax) }}
</div>
<div class="mt-1 flex flex-wrap gap-1" v-if="item.bookingMode && item.bookingMode !== 'expense'">
<UBadge size="xs" color="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</UBadge>
@@ -747,7 +777,7 @@ setup()
class="p-4 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 shadow-sm shrink-0 z-10">
<div class="grid grid-cols-12 gap-4 items-end">
<div class="col-span-12 md:col-span-3">
<UFormField label="Betrag" size="sm">
<UFormField label="Bruttobetrag" size="sm">
<UInput v-model="manualAllocationSum" type="number" step="0.01">
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
</UInput>
@@ -778,7 +808,7 @@ setup()
<UButton
icon="i-heroicons-check"
label="Buchen"
:disabled="!accountToSave"
:disabled="!accountToSave || !allocationTaxKey"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })"
/>
</div>
@@ -793,6 +823,28 @@ setup()
</div>
</div>
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-3">
<UFormField label="Umsatzsteuer / Vorsteuer" size="sm">
<USelectMenu v-model="allocationTaxKey" :items="BANK_ALLOCATION_TAX_ITEMS" value-key="value" label-key="label"
placeholder="Steuer auswählen …" class="w-full" />
</UFormField>
<UFormField label="Kostenstelle (optional)" size="sm">
<div class="flex gap-1">
<USelectMenu v-model="allocationCostcentre" :items="costcentres" value-key="id" label-key="name"
:filter-fields="['number', 'name']" placeholder="Keine Kostenstelle" class="w-full">
<template #default>{{ allocationCostcentre ? getCostCentreLabel(allocationCostcentre) : 'Keine Kostenstelle' }}</template>
<template #item-label="{ item }">{{ item.number }} - {{ item.name }}</template>
</USelectMenu>
<UButton v-if="allocationCostcentre" icon="i-heroicons-x-mark" variant="ghost" aria-label="Kostenstelle entfernen"
@click="allocationCostcentre = null" />
</div>
</UFormField>
<p v-if="allocationTaxKey && (accountToSave || ownAccountToSave)" class="md:col-span-2 text-xs text-gray-500">
Netto {{ displayCurrency(allocationAmounts.amountNet) }} · Steuer {{ displayCurrency(allocationAmounts.amountTax) }}
· Brutto {{ displayCurrency(allocationAmounts.amountGross) }}
</p>
</div>
<div v-if="showMoreWithoutRecipe"
class="mt-4 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3">
@@ -866,6 +918,7 @@ setup()
</USelectMenu>
<div class="md:col-span-3 flex justify-end">
<UButton size="xs" label="Zuweisen"
:disabled="!!ownAccountToSave && !allocationTaxKey"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, ownaccount: ownAccountToSave ? ownAccountToSave : null, customer: customerAccountToSave ? customerAccountToSave : null, vendor: vendorAccountToSave ? vendorAccountToSave : null, description: allocationDescription })"/>
</div>
</div>

View File

@@ -1,598 +1,27 @@
<script setup>
import { setPageLayout } from "#app"
import "gridstack/dist/gridstack.min.css"
const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue"))
const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue"))
const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue"))
const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue"))
const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue"))
const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue"))
const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue"))
import DashboardHome from "~/components/DashboardHome.vue"
setPageLayout("default")
const toast = useToast()
const tempStore = useTempStore()
const gridElement = ref(null)
const grid = shallowRef(null)
const isEditMode = ref(false)
const manageCardsOpen = ref(false)
const widgets = ref([])
let gridStackClass = null
let persistTimeout = null
const isSyncingGrid = ref(false)
const DASHBOARD_WIDGETS = [
{
id: "income-expense",
title: "Einnahmen und Ausgaben",
description: "Umsatz- und Ausgabenentwicklung",
component: markRaw(DisplayIncomeAndExpenditure),
defaultLayout: { x: 0, y: 0, w: 12, h: 4 },
minW: 4,
minH: 4
},
{
id: "open-balances",
title: "Buchhaltung",
description: "Offene Rechnungen und Entwurfsstatus",
component: markRaw(DisplayOpenBalances),
defaultLayout: { x: 0, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bankaccounts",
title: "Bank",
description: "Kontostand und offene Zuordnungen",
component: markRaw(DisplayBankaccounts),
defaultLayout: { x: 4, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "projects",
title: "Projekte",
description: "Aktuelle Projektphasen",
component: markRaw(DisplayProjectsInPhases),
defaultLayout: { x: 8, y: 4, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tasks",
title: "Aufgaben",
description: "Offene Aufgaben des aktuellen Nutzers",
component: markRaw(DisplayOpenTasks),
defaultLayout: { x: 0, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "tax-summary",
title: "USt aktuell",
description: "USt, Vorsteuer und Saldo des aktuellen Zeitraums",
component: markRaw(DisplayTaxSummary),
defaultLayout: { x: 4, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
},
{
id: "bwa-summary",
title: "BWA aktuell",
description: "Einnahmen, Ausgaben und Ergebnis des aktuellen Monats",
component: markRaw(DisplayBWASummary),
defaultLayout: { x: 8, y: 7, w: 4, h: 3 },
minW: 3,
minH: 3
}
]
const widgetDefinitions = Object.fromEntries(DASHBOARD_WIDGETS.map((widget) => [widget.id, widget]))
function getDefaultDashboardWidgets() {
return DASHBOARD_WIDGETS.map((definition) => ({
id: definition.id,
x: definition.defaultLayout.x,
y: definition.defaultLayout.y,
w: definition.defaultLayout.w,
h: definition.defaultLayout.h,
visible: true
}))
}
function normalizeNumber(value, fallback) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function serializeWidgets(input) {
return input.map((widget) => ({
id: widget.id,
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
visible: widget.visible
}))
}
function normalizeDashboardWidgets(storedWidgets) {
const storedById = new Map(
(Array.isArray(storedWidgets) ? storedWidgets : [])
.filter((widget) => widget?.id && widgetDefinitions[widget.id])
.map((widget) => [widget.id, widget])
)
return DASHBOARD_WIDGETS.map((definition) => {
const stored = storedById.get(definition.id) || {}
return {
id: definition.id,
x: normalizeNumber(stored.x, definition.defaultLayout.x),
y: normalizeNumber(stored.y, definition.defaultLayout.y),
w: normalizeNumber(stored.w, definition.defaultLayout.w),
h: normalizeNumber(stored.h, definition.defaultLayout.h),
visible: typeof stored.visible === "boolean" ? stored.visible : true
}
})
}
function widgetsSignature(input) {
return JSON.stringify(serializeWidgets(input))
}
function getWidgetLayout(id) {
return widgets.value.find((widget) => widget.id === id)
}
function updateWidget(id, patch) {
widgets.value = widgets.value.map((widget) => {
if (widget.id !== id) return widget
return { ...widget, ...patch }
})
}
function persistWidgets() {
tempStore.modifySettings("dashboard", {
version: 1,
widgets: serializeWidgets(widgets.value)
})
}
function schedulePersistWidgets() {
clearTimeout(persistTimeout)
persistTimeout = setTimeout(() => {
persistWidgets()
}, 120)
}
async function ensureGridStack() {
if (gridStackClass) return gridStackClass
const gridStackModule = await import("gridstack")
gridStackClass = gridStackModule.GridStack
return gridStackClass
}
function gridOptionsFor(widget) {
const definition = widgetDefinitions[widget.id]
return {
x: widget.x,
y: widget.y,
w: widget.w,
h: widget.h,
minW: definition.minW,
minH: definition.minH,
id: widget.id
}
}
function handleGridChange(_event, items = []) {
if (isSyncingGrid.value || !isEditMode.value) return
let hasChanges = false
items.forEach((item) => {
const widgetId = item.el?.dataset?.widgetId || item.id
if (!widgetId) return
const current = getWidgetLayout(widgetId)
if (!current) return
const nextPatch = {
x: normalizeNumber(item.x, current.x),
y: normalizeNumber(item.y, current.y),
w: normalizeNumber(item.w, current.w),
h: normalizeNumber(item.h, current.h)
}
if (current.x === nextPatch.x && current.y === nextPatch.y && current.w === nextPatch.w && current.h === nextPatch.h) {
return
}
updateWidget(widgetId, nextPatch)
hasChanges = true
})
if (hasChanges) schedulePersistWidgets()
}
function syncGridInteractivity() {
if (!grid.value) return
grid.value.enableMove(isEditMode.value)
grid.value.enableResize(isEditMode.value)
}
async function syncGridWithDom() {
if (!import.meta.client) return
await nextTick()
if (!gridElement.value) return
const GridStack = await ensureGridStack()
if (!grid.value) {
isSyncingGrid.value = true
grid.value = GridStack.init(
{
column: 12,
float: true,
margin: 16,
cellHeight: 96,
disableOneColumnMode: false,
handle: ".dashboard-widget-drag-handle",
resizable: {
handles: "all"
}
},
gridElement.value
)
grid.value.on("change", handleGridChange)
syncGridInteractivity()
isSyncingGrid.value = false
}
const visibleIds = new Set(visibleWidgets.value.map((widget) => widget.id))
isSyncingGrid.value = true
grid.value.batchUpdate()
;[...grid.value.engine.nodes].forEach((node) => {
const widgetId = node.el?.dataset?.widgetId || node.id
if (!node.el || !node.el.isConnected || !visibleIds.has(widgetId)) {
if (node.el) grid.value.removeWidget(node.el, false, false)
}
})
const domWidgets = [...gridElement.value.querySelectorAll(".grid-stack-item")]
domWidgets.forEach((element) => {
const widget = visibleWidgets.value.find((item) => item.id === element.dataset.widgetId)
if (!widget) return
if (!element.gridstackNode) {
grid.value.makeWidget(element)
}
grid.value.update(element, gridOptionsFor(widget))
})
grid.value.batchUpdate(false)
isSyncingGrid.value = false
}
function addWidget(id) {
const widget = getWidgetLayout(id)
if (!widget || widget.visible) return
updateWidget(id, { visible: true })
persistWidgets()
}
function toggleEditMode() {
isEditMode.value = !isEditMode.value
if (!isEditMode.value) manageCardsOpen.value = false
}
function removeWidget(id) {
if (visibleWidgets.value.length <= 1) {
toast.add({
title: "Letzte Karte",
description: "Mindestens eine Dashboard-Karte sollte sichtbar bleiben.",
color: "orange"
})
return
}
updateWidget(id, { visible: false })
persistWidgets()
}
function resetDashboard() {
widgets.value = getDefaultDashboardWidgets()
persistWidgets()
toast.add({
title: "Dashboard zurückgesetzt",
description: "Das Standardlayout wurde wiederhergestellt.",
color: "primary"
})
}
const visibleWidgets = computed(() =>
widgets.value
.filter((widget) => widget.visible)
.map((widget) => ({
...widgetDefinitions[widget.id],
...widget
}))
)
const hiddenWidgets = computed(() =>
DASHBOARD_WIDGETS.filter((definition) => !getWidgetLayout(definition.id)?.visible)
)
watch(
() => tempStore.settings?.dashboard,
(storedDashboard) => {
const nextWidgets = normalizeDashboardWidgets(storedDashboard?.widgets)
if (widgetsSignature(nextWidgets) === widgetsSignature(widgets.value)) return
widgets.value = nextWidgets
},
{ immediate: true, deep: true }
)
watch(
visibleWidgets,
async () => {
await syncGridWithDom()
},
{ deep: true }
)
watch(isEditMode, () => {
syncGridInteractivity()
})
onMounted(async () => {
if (!widgets.value.length) widgets.value = normalizeDashboardWidgets()
await syncGridWithDom()
})
onBeforeUnmount(() => {
clearTimeout(persistTimeout)
if (grid.value) {
grid.value.off("change", handleGridChange)
grid.value.destroy(false)
grid.value = null
}
})
const auth = useAuthStore()
</script>
<template>
<UDashboardNavbar title="Home">
<template #right>
<div class="flex items-center gap-2">
<UButton
:icon="isEditMode ? 'i-heroicons-check' : 'i-heroicons-pencil-square'"
:color="isEditMode ? 'primary' : 'gray'"
:variant="isEditMode ? 'solid' : 'ghost'"
@click="toggleEditMode"
>
{{ isEditMode ? "Bearbeitung beenden" : "Dashboard bearbeiten" }}
</UButton>
<UButton
v-if="isEditMode && hiddenWidgets.length > 0"
icon="i-heroicons-plus"
color="white"
variant="soft"
@click="manageCardsOpen = true"
>
Karte hinzufügen
</UButton>
<UButton
v-if="isEditMode"
icon="i-heroicons-arrow-path"
color="gray"
variant="ghost"
@click="resetDashboard"
>
Standardlayout
</UButton>
<UButton
v-if="isEditMode"
icon="i-heroicons-squares-2x2"
color="gray"
variant="ghost"
@click="manageCardsOpen = true"
>
Karten verwalten
</UButton>
</div>
<DashboardHome v-if="auth.activeTenant > 0" :key="auth.activeTenant" />
<template v-else>
<UDashboardNavbar title="Home" />
<UDashboardPanelContent>
<UCard class="mx-auto mt-8 max-w-lg">
<div class="space-y-4">
<h1 class="text-lg font-semibold">Mandant auswählen</h1>
<template v-if="auth.tenants.length">
<p class="text-sm text-muted">Wähle zuerst einen Mandanten, um dessen Dashboard zu laden.</p>
<TenantDropdown class="w-full" />
</template>
</UDashboardNavbar>
<div v-if="visibleWidgets.length > 0" ref="gridElement" class="grid-stack dashboard-grid overflow-y-auto">
<div
v-for="widget in visibleWidgets"
:key="widget.id"
:class="['grid-stack-item', isEditMode ? 'dashboard-widget-editing' : '']"
:data-widget-id="widget.id"
:gs-x="widget.x"
:gs-y="widget.y"
:gs-w="widget.w"
:gs-h="widget.h"
:gs-min-w="widget.minW"
:gs-min-h="widget.minH"
>
<div class="grid-stack-item-content dashboard-grid-item">
<div class="dashboard-widget-card border border-gray-200 dark:border-gray-800">
<div class="dashboard-widget-header border-b border-gray-200 dark:border-gray-800">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div :class="['dashboard-widget-drag-handle font-semibold', isEditMode ? 'cursor-move' : 'cursor-default']">
{{ widget.title }}
</div>
<p class="mt-1 text-sm">
{{ widget.description }}
</p>
</div>
<div class="dashboard-widget-header-actions">
<div :id="`dashboard-widget-header-actions-${widget.id}`" class="dashboard-widget-header-target" />
<UButtonGroup v-if="isEditMode" size="xs">
<UButton
color="gray"
variant="soft"
icon="i-heroicons-arrows-pointing-out"
class="dashboard-widget-drag-handle"
/>
<UButton
color="gray"
variant="soft"
icon="i-heroicons-x-mark"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(widget.id)"
/>
</UButtonGroup>
</div>
</div>
</div>
<div class="dashboard-widget-body">
<component
:is="widget.component"
v-bind="widget.id === 'income-expense'
? { headerTarget: `#dashboard-widget-header-actions-${widget.id}` }
: {}"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-10 text-center">
<p class="text-sm">
Es sind aktuell keine Dashboard-Karten sichtbar.
</p>
<UButton v-if="isEditMode" class="mt-4" icon="i-heroicons-plus" @click="manageCardsOpen = true">
Karte hinzufügen
</UButton>
</div>
<UModal v-model:open="manageCardsOpen">
<template #content>
<UCard>
<template #header>
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="font-semibold">Dashboard-Karten</h2>
<p class="text-sm">
Karten ein- oder ausblenden und bei Bedarf auf das Standardlayout zurücksetzen.
</p>
</div>
<UButton color="gray" variant="ghost" icon="i-heroicons-arrow-path" @click="resetDashboard">
Zurücksetzen
</UButton>
</div>
</template>
<div class="space-y-3">
<div
v-for="definition in DASHBOARD_WIDGETS"
:key="definition.id"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3"
>
<div>
<p class="font-medium">{{ definition.title }}</p>
<p class="text-sm">{{ definition.description }}</p>
</div>
<UButton
v-if="getWidgetLayout(definition.id)?.visible"
color="gray"
variant="soft"
icon="i-heroicons-minus"
:disabled="visibleWidgets.length <= 1"
@click="removeWidget(definition.id)"
>
Entfernen
</UButton>
<UButton
v-else
color="primary"
variant="soft"
icon="i-heroicons-plus"
@click="addWidget(definition.id)"
>
Hinzufügen
</UButton>
</div>
<p v-else class="text-sm text-muted">Deinem Benutzer ist noch kein Mandant zugeordnet.</p>
<UButton v-if="auth.user?.is_admin" to="/administration/tenants" variant="outline">Mandanten verwalten</UButton>
</div>
</UCard>
</UDashboardPanelContent>
</template>
</UModal>
</template>
<style scoped>
.dashboard-grid {
min-height: 60vh;
}
.dashboard-grid-item {
height: 100%;
padding: 0.25rem;
}
.dashboard-widget-card {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 0.75rem;
box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
}
.dashboard-widget-header {
flex: 0 0 auto;
padding: 1rem 1rem 0.875rem;
}
.dashboard-widget-header-actions {
display: flex;
flex: 0 0 auto;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-end;
gap: 0.5rem;
}
.dashboard-widget-header-target {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
}
.dashboard-widget-body {
flex: 1 1 auto;
min-height: 0;
overflow: hidden;
padding: 1rem;
}
:deep(.grid-stack-item-content) {
inset: 0;
background: transparent;
overflow-y: hidden !important;
}
:deep(.grid-stack-item:not(.dashboard-widget-editing) .ui-resizable-handle) {
display: none;
}
</style>

View File

@@ -0,0 +1,10 @@
export default defineEventHandler((event) => {
setHeader(event, 'Cache-Control', 'no-store')
const config = useRuntimeConfig(event)
return {
status: 'ok',
product: 'FEDEO',
version: config.public.fedeoVersion,
revision: config.public.fedeoRevision,
}
})

View File

@@ -280,7 +280,6 @@ export const useAuthStore = defineStore("auth", {
body: { email, password, rememberMe }
})
console.log("Token: " + token)
// Tokens abhängig von „Angemeldet bleiben“ dauerhaft oder nur für diese Sitzung speichern
this.setSession(token, refreshToken, rememberMe)
@@ -289,7 +288,6 @@ export const useAuthStore = defineStore("auth", {
// 2. User Daten laden
await this.fetchMe(token)
console.log(this.user)
// 3. WICHTIG: Jetzt explizit weiterleiten!
// Prüfen, ob der User geladen wurde
@@ -380,7 +378,6 @@ export const useAuthStore = defineStore("auth", {
})
// ... (Deine Logik für tenants, sorting etc. bleibt gleich) ...
console.log(me)
this.user = me.user
this.permissions = me.permissions
this.tenants = me.tenants
@@ -394,6 +391,9 @@ export const useAuthStore = defineStore("auth", {
if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config)
this.activeTenant = null
this.activeTenantData = null
if(me.activeTenant > 0) {
const normalizedActiveTenant = String(me.activeTenant)
this.activeTenant = normalizedActiveTenant
@@ -417,7 +417,6 @@ export const useAuthStore = defineStore("auth", {
this.scheduleSessionTimers(tokenToUse)
console.log(this)
} catch (err: any) {
// WICHTIG: Hier NICHT this.logout() aufrufen, weil das navigiert!
@@ -477,7 +476,6 @@ export const useAuthStore = defineStore("auth", {
refreshToken: this.getStoredRefreshToken()
}
})
console.log(res)
const {token, refreshToken} = res
this.setSession(token, refreshToken, this.sessionPersistent)

View File

@@ -0,0 +1,63 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const path = require('node:path')
function loadModule(file) {
const filename = path.resolve(__dirname, file)
const module = { exports: {} }
const localRequire = id => id.startsWith('.') ? loadModule(path.relative(__dirname, path.resolve(path.dirname(filename), id + '.ts'))) : require(id)
vm.runInNewContext(ts.transpile(fs.readFileSync(filename, 'utf8'), { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }), { module, exports: module.exports, require: localRequire })
return module.exports
}
const { getBankAllocationAmounts, getBankAllocationTaxBreakdown } = loadModule('../utils/bankAllocationTax.ts')
const { getStatementAccountExpenseTotal, getStatementAllocationDepreciationAmount } = loadModule('../composables/useDepreciation.ts')
const allocation = (amount, datevTaxKey, extra = {}) => ({ bankstatement: { id: 42 }, account: 13, amount, datevTaxKey, ...extra })
for (const [gross, key, net, tax, side, rate] of [
[-119, '9', -100, -19, 'input', 19], [-107, '8', -100, -7, 'input', 7],
[119, '3', 100, 19, 'output', 19], [107, '2', 100, 7, 'output', 7],
[59.5, '9', 50, 9.5, 'input', 19], [-59.5, '3', -50, -9.5, 'output', 19],
]) {
test(`gross ${gross} and key ${key} produce correct net, VAT and refund signs`, () => {
const item = allocation(gross, key)
const amounts = getBankAllocationAmounts(item)
assert.equal(amounts.amountGross, gross)
assert.equal(amounts.amountNet, net)
assert.equal(amounts.amountTax, tax)
const result = getBankAllocationTaxBreakdown(item)
assert.equal(result[side][`net${rate}`], net * (side === 'input' ? -1 : 1))
assert.equal(result[side][`tax${rate}`], tax * (side === 'input' ? -1 : 1))
})
}
test('rounding preserves gross, and tax-free and legacy allocations retain their amounts', () => {
const amounts = getBankAllocationAmounts(allocation(-10, '9'))
assert.equal(amounts.amountNet, -8.4)
assert.equal(amounts.amountTax, -1.6)
for (const key of ['40', null]) {
const value = getBankAllocationAmounts(allocation(-100, key))
assert.equal(value.amountNet, -100)
assert.equal(value.amountTax, 0)
}
})
test('invoice settlements, archived bookings and manual bookings are not counted again', () => {
for (const extra of [{ incominginvoice: 1 }, { createddocument: 1 }, { archived: true }, { bankstatement: null }]) {
const result = getBankAllocationTaxBreakdown(allocation(-119, '9', extra))
assert.ok([...Object.values(result.input), ...Object.values(result.output)].every(value => value === 0))
}
})
test('additional accounts carry the same tax breakdown', () => {
assert.equal(getBankAllocationAmounts(allocation(-119, '9', { account: null, ownaccount: 'own' })).amountTax, -19)
})
test('BWA separates net expenses from gross cash flows including partial refunds', () => {
const items = [allocation(-119, '9'), allocation(59.5, '9')]
assert.equal(getStatementAccountExpenseTotal(items), 50)
assert.equal(getStatementAccountExpenseTotal(items, true), 59.5)
})
test('depreciation uses the net acquisition amount when input VAT is selected', () => {
assert.equal(getStatementAllocationDepreciationAmount(allocation(-1428, '9', {
bookingMode: 'depreciation_single', depreciationMonths: 12, depreciationStartDate: '2026-01-01',
}), '2026-01-01', '2026-01-31'), 100)
})

View File

@@ -0,0 +1,132 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const { parse } = require('@vue/compiler-sfc')
const { compile } = require('@vue/compiler-dom')
function mountHome(auth) {
const requests = []
const { descriptor } = parse(fs.readFileSync(__dirname + '/../pages/index.client.vue', 'utf8'))
const context = vm.createContext({ useAuthStore: () => auth, setPageLayout() {} })
vm.runInContext(descriptor.scriptSetup.content.replace(/^import .*$/gm, ''), context)
const remove = node => {
if (node.parent) node.parent.children.splice(node.parent.children.indexOf(node), 1)
node.parent = null
}
const renderer = vue.createRenderer({
createElement: type => ({ type, children: [], parent: null }),
createText: text => ({ text, children: [], parent: null }),
createComment: text => ({ text, children: [], parent: null }),
setElementText: (node, text) => { node.text = text },
setText: (node, text) => { node.text = text },
parentNode: node => node.parent,
nextSibling: node => node.parent?.children[node.parent.children.indexOf(node) + 1] || null,
insert(node, parent, anchor) {
remove(node)
node.parent = parent
const index = anchor ? parent.children.indexOf(anchor) : -1
if (index < 0) parent.children.push(node)
else parent.children.splice(index, 0, node)
},
remove, patchProp() {},
})
const component = {
setup: () => vm.runInContext('({ auth })', context),
render: new Function('Vue', compile(descriptor.template.content, { mode: 'function', prefixIdentifiers: true }).code)(vue),
}
const app = renderer.createApp(component)
app.component('DashboardHome', {
setup() {
requests.push(auth.activeTenant)
return () => vue.h('div', 'Dashboard ' + auth.activeTenant)
},
})
for (const name of ['UDashboardNavbar', 'UDashboardPanelContent', 'UCard', 'UButton', 'TenantDropdown']) {
app.component(name, { setup: (_, { slots }) => () => vue.h('div', slots.default?.()) })
}
const root = { children: [] }
app.mount(root)
const content = node => [node.text || '', ...node.children.map(content)].join(' ')
return { requests, close: () => app.unmount(), text: () => content(root) }
}
test('dashboard data only mounts with a tenant and remounts when the tenant changes', async t => {
const auth = vue.reactive({ activeTenant: null, tenants: [{ id: '1' }, { id: '2' }], user: {} })
const ui = mountHome(auth)
t.after(ui.close)
assert.deepEqual(ui.requests, [])
assert.match(ui.text(), /Mandant auswählen/)
auth.activeTenant = '1'
await vue.nextTick()
assert.deepEqual(ui.requests, ['1'])
auth.activeTenant = '2'
await vue.nextTick()
assert.deepEqual(ui.requests, ['1', '2'])
auth.activeTenant = null
await vue.nextTick()
assert.deepEqual(ui.requests, ['1', '2'])
assert.match(ui.text(), /Mandant auswählen/)
})
test('an account without assigned tenants receives an explanation without loading widgets', t => {
const ui = mountHome(vue.reactive({ activeTenant: null, tenants: [], user: { is_admin: true } }))
t.after(ui.close)
assert.deepEqual(ui.requests, [])
assert.match(ui.text(), /noch kein Mandant zugeordnet/)
assert.match(ui.text(), /Mandanten verwalten/)
})
function authFixture() {
const logs = [], calls = [], sessions = []
const token = 'test-access-token'
const refreshToken = 'test-refresh-token'
let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null }
const context = vm.createContext({
exports: {}, process: { client: false },
defineStore: (_, definition) => definition,
console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) },
useTempStore: () => ({ setStoredTempConfig() {} }),
navigateTo() {},
useNuxtApp: () => ({ $api: async (url, options) => {
calls.push({ url, options })
if (url === '/api/me') return me
if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken }
throw Error('Unexpected API request: ' + url)
} }),
})
const source = fs.readFileSync(__dirname + '/../stores/auth.ts', 'utf8').replace(/^import .*$/gm, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), context)
const definition = context.exports.useAuthStore
const store = definition.state()
for (const [name, action] of Object.entries(definition.actions)) store[name] = action.bind(store)
store.getStoredToken = () => token
store.getStoredRefreshToken = () => refreshToken
store.scheduleSessionTimers = () => {}
store.setSession = (...args) => sessions.push(args)
return { store, logs, calls, sessions, token, refreshToken, setMe: value => { me = value } }
}
test('refreshing a session without an active tenant clears the previous tenant state', async () => {
const f = authFixture()
f.store.activeTenant = '12'
f.store.activeTenantData = { id: '12', name: 'Old tenant' }
await f.store.fetchMe()
assert.equal(f.store.activeTenant, null)
assert.equal(f.store.activeTenantData, null)
assert.deepEqual(f.calls.map(call => call.url), ['/api/me'])
})
test('login and tenant switching store credentials without writing them to the console', async () => {
const f = authFixture()
await f.store.login('test@example.com', 'test-password')
await f.store.switchTenant('1')
assert.equal(f.sessions.length, 2)
assert.equal(f.sessions[0][0], f.token)
assert.equal(f.sessions[1][1], f.refreshToken)
const output = JSON.stringify(f.logs)
assert.ok(!output.includes(f.token))
assert.ok(!output.includes(f.refreshToken))
})

View File

@@ -0,0 +1,101 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const vue = require('vue')
const check = { status: 'checked', command: 'check', requestId: 'checked-request', plan: {
version: '3.2.0', revision: 'a'.repeat(40), updateAvailable: true, requiresComposeAdoption: false, retiredServices: [],
} }
const overview = (job = check, engine = { status: 'checked' }) => ({ acceptingRequests: true, engine, request: job, installed: {}, backend: { version: '3.1.0' } })
async function setup(t, api, saved = null) {
const mounted = [], unmounted = [], calls = [], storage = new Map(saved ? [['fedeo:update:admin', saved]] : [])
let nextId = 0
const context = vm.createContext({
ref: vue.ref, computed: vue.computed, onMounted: fn => mounted.push(fn), onBeforeUnmount: fn => unmounted.push(fn),
useAuthStore: () => ({ user: { id: 'admin' } }),
useNuxtApp: () => ({ $api: async (path, options) => { calls.push({ path, options }); return api(path, options) } }),
localStorage: { getItem: key => storage.get(key), setItem: (key, value) => storage.set(key, value) },
crypto: { randomUUID: () => `request-id-${++nextId}` }, setTimeout: () => 1, clearTimeout() {},
})
const source = fs.readFileSync(__dirname + '/../composables/useSelfhostUpdates.ts', 'utf8').replace(/export /g, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.None }) + '\nglobalThis.ui = useSelfhostUpdates()', context)
mounted.forEach(fn => fn()); await new Promise(setImmediate)
t.after(() => unmounted.forEach(fn => fn()))
return { ui: context.ui, calls, storage }
}
test('checked version enables one apply; duplicate click is blocked and polling survives restart', async t => {
let state = overview(), offline = false, finish
const f = await setup(t, async (path, options) => {
if (offline) throw new Error('network')
if (options.method === 'POST') return new Promise(resolve => { finish = resolve })
return path.endsWith('/status') ? state : state.request
})
assert.equal(f.ui.canApply.value, true)
const start = f.ui.apply(); await f.ui.apply()
assert.equal(f.calls.filter(c => c.options.method === 'POST').length, 1)
const body = f.calls.at(-1).options.body
assert.equal(body.version, '3.2.0'); assert.equal(body.checkRequestId, 'checked-request')
state = overview({ status: 'running', command: 'apply', requestId: body.requestId }, { status: 'running', recoveryRequired: true })
finish(state.request); await start
await f.ui.refresh(); assert.equal(f.ui.recovery.value, false)
offline = true; await f.ui.refresh()
assert.match(f.ui.message.value, /Host führt das Update weiter/)
assert.equal(f.ui.canApply.value, false)
offline = false; state = overview({ ...state.request, status: 'succeeded' }, { status: 'succeeded' })
await f.ui.refresh(); assert.equal(f.ui.job.value.status, 'succeeded')
})
test('lost start response is recovered after reload without replaying an update', async t => {
const first = await setup(t, async (path, options) => {
if (options.method === 'POST') throw new Error('response lost')
return path.endsWith('/status') ? overview() : check
})
await first.ui.apply()
const id = first.ui.pending.value.body.requestId
const saved = first.storage.get('fedeo:update:admin')
const running = { status: 'running', command: 'apply', requestId: id }
const second = await setup(t, async path => path.endsWith('/status') ? overview(running) : running, saved)
assert.equal(second.ui.pending.value, null)
assert.equal(second.ui.job.value.requestId, id)
assert.equal(second.calls.some(c => c.options.method === 'POST'), false)
})
test('unconfirmed submission retains its original ID for explicit retry', async t => {
const saved = JSON.stringify({ pending: { command: 'apply', body: { requestId: 'original-request', version: '3.2.0', checkRequestId: 'checked-request' } } })
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') return { status: 'queued', command: 'apply', requestId: options.body.requestId }
if (path.endsWith('/status')) return overview()
throw { statusCode: 404 }
}, saved)
assert.equal(f.ui.canApply.value, false)
await f.ui.sendPending()
assert.equal(f.calls.at(-1).options.body.requestId, 'original-request')
})
test('recovery, up-to-date, unmanaged compose and other active jobs prevent applying', async t => {
for (const state of [overview(check, { status: 'failed', recoveryRequired: true }),
overview({ ...check, status: 'up_to_date' }),
overview({ ...check, plan: { ...check.plan, requiresComposeAdoption: true } }),
overview({ status: 'running', command: 'apply', requestId: 'someone-else' })]) {
const f = await setup(t, async () => state)
assert.equal(f.ui.canApply.value, false)
await f.ui.apply(); assert.equal(f.calls.some(c => c.options.method === 'POST'), false)
}
})
test('an older polling response cannot erase a new submission whose response was lost', async t => {
let hold = false, release
const f = await setup(t, async (path, options) => {
if (options.method === 'POST') throw new Error('response lost')
if (path.endsWith('/status')) return overview()
if (hold) return new Promise(resolve => { release = resolve })
return check
})
hold = true
const polling = f.ui.refresh()
await new Promise(setImmediate)
await f.ui.apply()
const id = f.ui.pending.value.body.requestId
release(check); await polling
assert.equal(f.ui.pending.value.body.requestId, id)
assert.equal(f.ui.canApply.value, false)
})

View File

@@ -0,0 +1,43 @@
export const BANK_ALLOCATION_TAX_ITEMS = [
{ value: "9", label: "19 % Vorsteuer", rate: 19, kind: "input" },
{ value: "8", label: "7 % Vorsteuer", rate: 7, kind: "input" },
{ value: "3", label: "19 % Umsatzsteuer", rate: 19, kind: "output" },
{ value: "2", label: "7 % Umsatzsteuer", rate: 7, kind: "output" },
{ value: "40", label: "Ohne Umsatzsteuer", rate: 0, kind: "none" },
]
export const isDirectBankAllocation = (allocation: any) => Boolean(
allocation?.bankstatement && (allocation.account || allocation.ownaccount)
&& !allocation.createddocument && !allocation.incominginvoice
&& !allocation.customer && !allocation.vendor && !allocation.archived
)
// The signed bank amount always remains gross. Refunds retain their sign.
export function getBankAllocationAmounts(allocation: any) {
const amountGross = Number(allocation?.amount || 0)
const tax = isDirectBankAllocation(allocation)
? BANK_ALLOCATION_TAX_ITEMS.find(item => item.value === String(allocation.datevTaxKey))
: undefined
const rate = tax?.rate || 0
const amountNet = Number((amountGross / (1 + rate / 100)).toFixed(2))
const amountTax = Number((amountGross - amountNet).toFixed(2))
return { amountGross, amountNet, amountTax, rate, kind: tax?.kind || "none" }
}
export function getBankAllocationTaxBreakdown(allocation: any) {
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
const input = { ...output }
if (!isDirectBankAllocation(allocation)) return { output, input }
const { amountNet, amountTax, rate, kind } = getBankAllocationAmounts(allocation)
if (kind === "none") return { output, input }
const target = kind === "input" ? input : output
const sign = kind === "input" ? -1 : 1
if (rate === 19) {
target.net19 = amountNet * sign
target.tax19 = amountTax * sign
} else if (rate === 7) {
target.net7 = amountNet * sign
target.tax7 = amountTax * sign
}
return { output, input }
}

View File

@@ -4,7 +4,8 @@
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"test": "node --test scripts/release/tests/*.test.mjs",
"test": "node --test scripts/release/tests/*.test.mjs && npm run test:update",
"test:update": "python3 -B -m unittest discover -s scripts/update/tests -v",
"commits:check": "node scripts/release/cli.mjs check",
"release:preview": "node scripts/release/cli.mjs preview",
"release:ci": "node scripts/release/ci.mjs"

View File

@@ -0,0 +1,33 @@
[Unit]
Description=FEDEO host update service
Wants=network-online.target
After=network-online.target docker.service
Requires=docker.service
[Service]
Type=simple
User=root
Group=fedeo-updater
Environment=HOME=/root
Environment=PYTHONDONTWRITEBYTECODE=1
EnvironmentFile=-/etc/fedeo/updater.env
ExecStart=/usr/bin/python3 -B /usr/local/lib/fedeo-updater/selfhost-update-service.py serve --directory @FEDEO_DIRECTORY@
Restart=on-failure
RestartSec=5s
KillMode=mixed
TimeoutStopSec=infinity
UMask=0077
RuntimeDirectory=fedeo-updater
RuntimeDirectoryMode=0750
RuntimeDirectoryPreserve=yes
NoNewPrivileges=true
ProtectSystem=full
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

View File

@@ -32,6 +32,11 @@ export function releaseFiles(manifest, notes) {
['.env.example', Buffer.from(readFileSync('.env.example', 'utf8').replace(/^FEDEO_VERSION=.*$/m, `FEDEO_VERSION=${manifest.version}`))],
['selfhost-install.sh', Buffer.from(readFileSync('scripts/selfhost-install.sh', 'utf8').replace('FEDEO_REF:-dev', `FEDEO_REF:-${manifest.tag}`))],
['selfhost-setup.sh', Buffer.from(readFileSync('scripts/selfhost-setup.sh', 'utf8').replaceAll('FEDEO_VERSION:-dev', `FEDEO_VERSION:-${manifest.version}`))],
['selfhost-update.py', Buffer.from(readFileSync('scripts/selfhost-update.py', 'utf8'))],
['selfhost-update-service.py', Buffer.from(readFileSync('scripts/selfhost-update-service.py', 'utf8'))],
['selfhost-update-service-install.sh', Buffer.from(readFileSync('scripts/selfhost-update-service-install.sh', 'utf8'))],
['fedeo-updater.service', Buffer.from(readFileSync('scripts/fedeo-updater.service', 'utf8'))],
['SELFHOST-UPDATES.md', Buffer.from(readFileSync('docs/selfhost-updates.md', 'utf8'))],
]);
files.set('SHA256SUMS', Buffer.from([...files].map(([name, data]) => `${createHash('sha256').update(data).digest('hex')} ${name}`).join('\n') + '\n'));
return files;

View File

@@ -152,6 +152,10 @@ test('release assets pin compose, environment and installer to one version', ()
assert.doesNotMatch(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-dev/);
assert.match(files.get('.env.example').toString(), /^FEDEO_VERSION=1\.2\.0$/m);
assert.match(files.get('selfhost-install.sh').toString(), /FEDEO_REF:-v1\.2\.0/);
for (const name of ['selfhost-update-service.py', 'selfhost-update-service-install.sh', 'fedeo-updater.service']) {
assert.ok(files.get(name)?.length > 0, `missing service asset: ${name}`);
assert.ok(files.get('SHA256SUMS').toString().includes(` ${name}\n`));
}
assert.equal(files.get('SHA256SUMS').toString().trim().split('\n').length, files.size - 1);
});

View File

@@ -80,7 +80,7 @@ have() {
install_base_packages() {
local missing=()
for binary in curl openssl; do
for binary in curl openssl python3; do
if ! have "$binary"; then
missing+=("$binary")
fi
@@ -104,7 +104,7 @@ install_base_packages() {
echo "Installiere Basispakete: ${missing[*]}"
$SUDO apt-get update
$SUDO apt-get install -y ca-certificates curl openssl
$SUDO apt-get install -y ca-certificates curl openssl python3
}
ensure_docker() {
@@ -185,6 +185,11 @@ download_selfhost_files() {
download_file "$FEDEO_RAW_BASE_URL/docker-compose.selfhost.yml" "$FEDEO_DIR/docker-compose.yml"
download_file "$FEDEO_RAW_BASE_URL/.env.example" "$FEDEO_DIR/.env.example"
download_file "$FEDEO_RAW_BASE_URL/scripts/selfhost-setup.sh" "$FEDEO_DIR/scripts/selfhost-setup.sh"
download_file "$FEDEO_RAW_BASE_URL/scripts/selfhost-update.py" "$FEDEO_DIR/scripts/selfhost-update.py"
download_file "$FEDEO_RAW_BASE_URL/scripts/selfhost-update-service.py" "$FEDEO_DIR/scripts/selfhost-update-service.py"
download_file "$FEDEO_RAW_BASE_URL/scripts/selfhost-update-service-install.sh" "$FEDEO_DIR/scripts/selfhost-update-service-install.sh"
download_file "$FEDEO_RAW_BASE_URL/scripts/fedeo-updater.service" "$FEDEO_DIR/scripts/fedeo-updater.service"
download_file "$FEDEO_RAW_BASE_URL/docs/selfhost-updates.md" "$FEDEO_DIR/SELFHOST-UPDATES.md"
chmod +x "$FEDEO_DIR/scripts/selfhost-setup.sh"
}

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env bash
set -euo pipefail
FEDEO_DIR=/opt/fedeo
PRINT_UNIT=false
SOURCE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
while [[ $# -gt 0 ]]; do
case "$1" in
--directory) FEDEO_DIR="${2:?Verzeichnis fehlt}"; shift 2 ;;
--print-unit) PRINT_UNIT=true; shift ;;
--help|-h) echo "sudo bash $0 [--directory /opt/fedeo] [--print-unit]"; exit 0 ;;
*) echo "Unbekanntes Argument: $1" >&2; exit 1 ;;
esac
done
render_unit() {
python3 - "$SOURCE_DIR/fedeo-updater.service" "$FEDEO_DIR" <<'PY'
import pathlib, sys
directory = sys.argv[2]
if not directory.startswith('/') or any(c in directory for c in '\n\r\0'):
raise SystemExit('Ein gültiges absolutes Betriebsverzeichnis angeben.')
quoted = '"' + directory.replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%').replace('$', '$$') + '"'
sys.stdout.write(pathlib.Path(sys.argv[1]).read_text().replace('@FEDEO_DIRECTORY@', quoted))
PY
}
if [[ "$PRINT_UNIT" == true ]]; then
render_unit
exit 0
fi
[[ "$(id -u)" -eq 0 ]] || { echo "Als root ausführen." >&2; exit 1; }
[[ -d /run/systemd/system ]] || { echo "Ein Linux-Host mit laufendem systemd wird benötigt." >&2; exit 1; }
FEDEO_DIR="$(realpath -e -- "$FEDEO_DIR")"
[[ -f "$FEDEO_DIR/.env" && -f "$FEDEO_DIR/docker-compose.yml" && ! -e "$FEDEO_DIR/.git" ]] || {
echo "Ein bestehendes Selfhost-Betriebsverzeichnis ohne Git-Checkout angeben." >&2; exit 1;
}
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else "Python ab 3.10 wird benötigt.")'
for name in selfhost-update.py selfhost-update-service.py fedeo-updater.service; do
[[ -f "$SOURCE_DIR/$name" ]] || { echo "Fehlende Installationsdatei: $name" >&2; exit 1; }
done
python3 - "$SOURCE_DIR" <<'PY'
import importlib.util, inspect, pathlib, sys
source = pathlib.Path(sys.argv[1])
spec = importlib.util.spec_from_file_location('fedeo_update_install_check', source / 'selfhost-update.py')
engine = importlib.util.module_from_spec(spec)
spec.loader.exec_module(engine)
if not {'expected_fingerprint', 'request_id'}.issubset(inspect.signature(engine.Updater.execute).parameters):
raise SystemExit('Updater und Dienst passen nicht zusammen. Alle Dateien aus demselben Stand bereitstellen.')
compile((source / 'selfhost-update-service.py').read_text(), 'selfhost-update-service.py', 'exec')
PY
command -v flock >/dev/null
command -v docker >/dev/null
command -v curl >/dev/null
docker compose version >/dev/null
UNIT_CONTENT="$(render_unit)"
# Drain an active service job before replacing either Python module. No containers are restarted here.
if systemctl is-active --quiet fedeo-updater.service; then
systemctl stop fedeo-updater.service
fi
[[ ! -L "$FEDEO_DIR/.updates" ]] || { echo ".updates darf kein symbolischer Link sein." >&2; exit 1; }
install -d -m 0700 "$FEDEO_DIR/.updates"
exec 9>"$FEDEO_DIR/.updates/lock"
flock -n 9 || { echo "Ein CLI-Update läuft noch. Dienstinstallation später wiederholen." >&2; exit 1; }
getent group fedeo-updater >/dev/null || groupadd --system fedeo-updater
install -d -o root -g root -m 0700 /usr/local/lib/fedeo-updater /etc/fedeo
install -o root -g root -m 0700 "$SOURCE_DIR/selfhost-update.py" /usr/local/lib/fedeo-updater/selfhost-update.py
install -o root -g root -m 0700 "$SOURCE_DIR/selfhost-update-service.py" /usr/local/lib/fedeo-updater/selfhost-update-service.py
if [[ ! -e /etc/fedeo/updater.env ]]; then
(umask 077; printf '%s\n' '# Optional: FEDEO_RELEASE_TOKEN=... (nur Leserechte)' > /etc/fedeo/updater.env)
fi
chmod 0600 /etc/fedeo/updater.env
UNIT_TMP="$(mktemp /etc/systemd/system/fedeo-updater.service.XXXXXX)"
trap 'rm -f -- "$UNIT_TMP"' EXIT
printf '%s\n' "$UNIT_CONTENT" > "$UNIT_TMP"
chmod 0644 "$UNIT_TMP"
mv -- "$UNIT_TMP" /etc/systemd/system/fedeo-updater.service
flock -u 9
exec 9>&-
systemctl daemon-reload
systemctl enable --now fedeo-updater.service
READY=false
for attempt in {1..50}; do
if curl --silent --fail --max-time 1 --unix-socket /run/fedeo-updater/control.sock http://localhost/v1/status >/dev/null; then
READY=true
break
fi
sleep 0.1
done
systemctl --no-pager --full status fedeo-updater.service
[[ "$READY" == true ]] || { echo "Dienstschnittstelle noch nicht erreichbar; journalctl -u fedeo-updater.service prüfen." >&2; exit 1; }
echo "Lokale Schnittstelle: /run/fedeo-updater/control.sock"

View File

@@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""Local HTTP/Unix-socket control service for the FEDEO host updater. No TCP listener."""
import argparse
import contextlib
import fcntl
from http.server import BaseHTTPRequestHandler
import importlib.util
import json
import os
from pathlib import Path
import re
import signal
import socketserver
import stat
import threading
import traceback
spec = importlib.util.spec_from_file_location("fedeo_updater", Path(__file__).with_name("selfhost-update.py"))
engine = importlib.util.module_from_spec(spec)
spec.loader.exec_module(engine)
REQUEST_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9_-]{7,79}\Z")
PHASES = {"preparing", "validating", "checked", "pulling", "maintenance", "backed_up", "applying",
"retiring_matrix", "migrating", "starting", "verifying", "complete", "resolved"}
STATUSES = {"idle", "queued", "running", "checked", "up_to_date", "succeeded", "failed", "interrupted", "recovered"}
ERRORS = {
"INVALID_REQUEST": "Ungültige Anfrage.",
"NOT_FOUND": "Auftrag oder Endpunkt nicht gefunden.",
"BUSY": "Eine Prüfung oder ein Update läuft bereits.",
"SHUTTING_DOWN": "Der Dienst wird beendet.",
"REQUEST_CONFLICT": "Diese Auftrags-ID wurde für eine andere Anfrage verwendet.",
"CHECK_REQUIRED": "Zuerst genau diese Zielversion erfolgreich prüfen.",
"COMPOSE_REVIEW_REQUIRED": "Die Compose-Datei muss zuerst auf dem Host geprüft und übernommen werden.",
"RECOVERY_REQUIRED": "Ein vorheriges Update benötigt Wiederherstellung auf dem Host.",
"PLAN_CHANGED": "Release oder Konfiguration haben sich geändert. Erneut prüfen.",
"UPDATE_FAILED": "Auftrag fehlgeschlagen. Geschützte Protokolle auf dem Host prüfen.",
"INTERRUPTED": "Der Auftrag wurde unterbrochen. Status auf dem Host prüfen.",
}
class ApiError(Exception):
def __init__(self, code, status=400):
self.code, self.status = code, status
def identifier(value):
if not isinstance(value, str) or not REQUEST_ID.fullmatch(value):
raise ApiError("INVALID_REQUEST")
return value
def public_state(state):
"""An allowlist, never raw command logs, exceptions, host paths, config or diff contents."""
result = {"status": state.get("status") if state.get("status") in STATUSES else "idle"}
if state.get("phase") in PHASES:
result["phase"] = state["phase"]
for key in ("recoveryRequired", "maintenance", "mutationStarted"):
if key in state:
result[key] = bool(state[key])
for key in ("version", "previousVersion"):
if isinstance(state.get(key), str) and engine.VERSION.fullmatch(state[key]):
result[key] = state[key]
if re.fullmatch(r"[a-f0-9]{40}", str(state.get("revision", ""))):
result["revision"] = state["revision"]
for key in ("startedAt", "updatedAt"):
if isinstance(state.get(key), str) and re.fullmatch(r"[0-9T:+.Z-]{10,40}", state[key]):
result[key] = state[key]
if state.get("requestId") and REQUEST_ID.fullmatch(str(state["requestId"])):
result["requestId"] = state["requestId"]
if state.get("command") in ("check", "apply"):
result["command"] = state["command"]
return result
def public_plan(plan):
result = {k: v for k, v in public_state(plan).items() if k != "status"}
for key in ("updateAvailable", "requiresComposeAdoption"):
result[key] = bool(plan.get(key))
if type(plan.get("estimatedBackupBytes")) is int:
result["estimatedBackupBytes"] = plan["estimatedBackupBytes"]
result["retiredServices"] = sorted(set(plan.get("retiredServices", [])) & engine.LEGACY_MATRIX_SERVICES)
return result
class Controller:
def __init__(self, directory, *, updater_factory=engine.Updater):
self.directory = Path(directory).resolve()
self.factory = updater_factory
updater = self.factory(self.directory)
self.home = updater.home / "service"
self.home.mkdir(mode=0o700, exist_ok=True)
if self.home.is_symlink():
raise engine.UpdateError("Dienstverzeichnis darf kein symbolischer Link sein.")
os.chmod(self.home, 0o700)
self.gate = threading.Lock()
self.worker = None
self.closing = False
self.active = None
self.daemon_lock = (self.home / "daemon.lock").open("a")
try:
fcntl.flock(self.daemon_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
self.daemon_lock.close()
raise engine.UpdateError("Für diese Instanz läuft bereits ein Update-Dienst.") from None
# A persisted request is an audit record, not a queue that silently replays updates.
try:
for path in self.home.glob("*.json"):
record = engine.read_json(path)
if record.get("status") in ("queued", "running"):
record.update(status="interrupted", errorCode="INTERRUPTED", updatedAt=engine.now())
engine.write_json(path, record)
if (REQUEST_ID.fullmatch(str(record.get("requestId", "")))
and (self.active is None or record.get("startedAt", "") > self.read(self.active).get("startedAt", ""))):
self.active = record["requestId"]
updater.status() # Detect an abandoned engine job without touching an active CLI job.
except BaseException:
self.daemon_lock.close()
raise
def path(self, request_id):
return self.home / (identifier(request_id) + ".json")
def read(self, request_id):
record = engine.read_json(self.path(request_id))
if not record:
raise ApiError("NOT_FOUND", 404)
return record
def request_status(self, request_id):
record = self.read(request_id)
result = public_state(record)
if record.get("plan"):
result["plan"] = public_plan(record["plan"])
code = record.get("errorCode")
if code in ERRORS:
result["error"] = {"code": code, "message": ERRORS[code]}
if record["status"] == "running":
state = self.factory(self.directory).status()
if state.get("requestId") == request_id:
result["progress"] = public_state(state)
return result
def status(self):
updater = self.factory(self.directory)
result = {"apiVersion": 1, "engine": public_state(updater.status()), "acceptingRequests": not self.closing}
installed = engine.read_json(updater.home / "installed.json", {}).get("manifest", {})
result["installed"] = {k: v for k, v in public_state({"version": installed.get("version"),
"revision": installed.get("commit")}).items() if k != "status"}
if self.active:
result["request"] = self.request_status(self.active)
return result
def submit(self, command, body):
if not isinstance(body, dict) or command not in ("check", "apply"):
raise ApiError("INVALID_REQUEST")
allowed = {"requestId", "version"} | ({"checkRequestId"} if command == "apply" else set())
if set(body) - allowed:
raise ApiError("INVALID_REQUEST")
request_id = identifier(body.get("requestId"))
version = body.get("version")
if version is not None and (not isinstance(version, str) or not engine.VERSION.fullmatch(version)):
raise ApiError("INVALID_REQUEST")
if command == "apply" and not version:
raise ApiError("INVALID_REQUEST")
check_id = identifier(body.get("checkRequestId")) if command == "apply" else None
payload = {"command": command, "version": version, "checkRequestId": check_id}
with self.gate:
previous = engine.read_json(self.path(request_id))
if previous:
if previous["input"] != payload:
raise ApiError("REQUEST_CONFLICT", 409)
return 200, self.request_status(request_id)
if self.closing:
raise ApiError("SHUTTING_DOWN", 503)
if self.worker and self.worker.is_alive():
raise ApiError("BUSY", 409)
state = self.factory(self.directory).status()
if state.get("recoveryRequired"):
raise ApiError("RECOVERY_REQUIRED", 409)
if state.get("status") == "running":
raise ApiError("BUSY", 409)
fingerprint = None
if command == "apply":
try:
check = self.read(check_id)
except ApiError:
raise ApiError("CHECK_REQUIRED", 409) from None
plan = check.get("plan", {})
if (check.get("command") != "check" or check.get("status") != "checked"
or plan.get("version") != version or not plan.get("updateAvailable")
or not re.fullmatch(r"[a-f0-9]{64}", str(plan.get("approvalFingerprint", "")))):
raise ApiError("CHECK_REQUIRED", 409)
if plan.get("requiresComposeAdoption"):
raise ApiError("COMPOSE_REVIEW_REQUIRED", 409)
fingerprint = plan["approvalFingerprint"]
record = {"requestId": request_id, "command": command, "input": payload,
"status": "queued", "startedAt": engine.now(), "updatedAt": engine.now()}
engine.write_json(self.path(request_id), record)
self.active = request_id
self.worker = threading.Thread(target=self.run, args=(record, fingerprint), daemon=False)
try:
self.worker.start()
except BaseException:
record.update(status="failed", errorCode="UPDATE_FAILED", updatedAt=engine.now())
engine.write_json(self.path(request_id), record)
raise
return 202, public_state(record)
def run(self, record, fingerprint):
request_id = record["requestId"]
try:
record.update(status="running", updatedAt=engine.now())
engine.write_json(self.path(request_id), record)
updater = self.factory(self.directory)
result = updater.execute(record["command"], record["input"]["version"],
expected_fingerprint=fingerprint, request_id=request_id)
record.update(status=result["status"], plan=result["plan"])
if record["command"] == "check" and not result["plan"]["updateAvailable"]:
record["status"] = "up_to_date"
except BaseException as error:
code = "PLAN_CHANGED" if isinstance(error, engine.PlanChangedError) else "UPDATE_FAILED"
record.update(status="failed", errorCode=code)
with (self.home / (request_id + ".log")).open("a") as log:
os.chmod(log.name, 0o600)
traceback.print_exc(file=log)
finally:
record["updatedAt"] = engine.now()
engine.write_json(self.path(request_id), record)
def close(self):
with self.gate:
self.closing = True
worker = self.worker
if worker:
worker.join() # Normal service stop drains the active update instead of interrupting a migration.
self.daemon_lock.close()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.0"
server_version = "FEDEO-Updater/1"
sys_version = ""
def setup(self):
super().setup()
self.connection.settimeout(5)
def log_message(self, *_):
pass
def send_json(self, status, body):
data = json.dumps(body, ensure_ascii=False).encode()
self.close_connection = True
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("Connection", "close")
self.end_headers()
self.wfile.write(data)
def dispatch(self):
try:
if self.headers.get("Origin") or self.headers.get("Transfer-Encoding"):
raise ApiError("INVALID_REQUEST")
controller = self.server.controller
if self.command == "GET" and self.path == "/v1/status":
self.send_json(200, controller.status())
elif self.command == "GET" and self.path.startswith("/v1/requests/"):
self.send_json(200, controller.request_status(self.path.removeprefix("/v1/requests/")))
elif self.command == "POST" and self.path in ("/v1/check", "/v1/apply"):
lengths = self.headers.get_all("Content-Length", [])
if (len(lengths) != 1 or not lengths[0].isdigit() or not 1 <= int(lengths[0]) <= 4096
or self.headers.get_content_type() != "application/json"):
raise ApiError("INVALID_REQUEST")
body = json.loads(self.rfile.read(int(lengths[0])))
status, result = controller.submit(self.path.rsplit("/", 1)[-1], body)
self.send_json(status, result)
else:
raise ApiError("NOT_FOUND", 404)
except ApiError as error:
self.send_json(error.status, {"error": {"code": error.code, "message": ERRORS[error.code]}})
except (ValueError, UnicodeError):
self.send_json(400, {"error": {"code": "INVALID_REQUEST", "message": ERRORS["INVALID_REQUEST"]}})
except (OSError, engine.UpdateError, KeyError, TypeError):
with contextlib.suppress(OSError):
self.send_json(500, {"error": {"code": "UPDATE_FAILED", "message": ERRORS["UPDATE_FAILED"]}})
do_GET = dispatch
do_POST = dispatch
class Server(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True
request_queue_size = 8
def __init__(self, path, controller):
self.controller = controller
self.slots = threading.BoundedSemaphore(16)
super().__init__(str(path), Handler)
os.chmod(path, 0o660)
def process_request(self, request, client_address):
if not self.slots.acquire(blocking=False):
self.shutdown_request(request)
return
try:
super().process_request(request, client_address)
except BaseException:
self.slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self.slots.release()
def handle_error(self, *_):
pass # Do not send request data or tracebacks to the journal.
def serve(directory, socket_path):
controller = None
path = Path(socket_path)
stop = threading.Event()
server = None
socket_lock = None
try:
path.parent.mkdir(mode=0o750, parents=True, exist_ok=True)
parent = path.parent.stat()
if path.parent.is_symlink() or parent.st_uid != 0 or parent.st_mode & 0o022:
raise engine.UpdateError("Socket-Verzeichnis muss root gehören und darf nicht durch andere beschreibbar sein.")
socket_lock = path.with_name(path.name + ".lock").open("a")
try:
fcntl.flock(socket_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
raise engine.UpdateError("An diesem Socket läuft bereits ein Update-Dienst.") from None
controller = Controller(directory)
if path.is_symlink() or (path.exists() and not stat.S_ISSOCK(path.stat().st_mode)):
raise engine.UpdateError("Socket-Pfad ist bereits anderweitig belegt.")
# Both the instance and endpoint are locked before replacing a stale socket.
path.unlink(missing_ok=True)
server = Server(path, controller)
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, lambda *_: stop.set())
server.timeout = 0.5
while not stop.is_set():
server.handle_request()
finally:
if controller:
controller.close()
if server:
server.server_close()
path.unlink(missing_ok=True)
if socket_lock:
socket_lock.close()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("serve",))
parser.add_argument("--directory", default="/opt/fedeo")
parser.add_argument("--socket", default="/run/fedeo-updater/control.sock")
args = parser.parse_args()
if os.geteuid() != 0:
parser.error("Der Host-Dienst muss als root laufen.")
os.umask(0o077)
try:
serve(args.directory, args.socket)
except (OSError, engine.UpdateError) as error:
parser.exit(1, f"Update-Dienst konnte nicht gestartet werden: {error}\n")
if __name__ == "__main__":
main()

746
scripts/selfhost-update.py Normal file
View File

@@ -0,0 +1,746 @@
#!/usr/bin/env python3
"""FEDEO host-side update engine. Python 3.10+, Docker Compose 2.24+; no pip packages.
The CLI and a future host service share Updater. No daemon or HTTP API is installed.
All commands operate on an existing local selfhost installation, never a git checkout.
"""
import argparse
import contextlib
import datetime
import difflib
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
REPOSITORY = "https://git.federspiel.tech/flfeders/FEDEO"
REGISTRY = "git.federspiel.tech/flfeders/fedeo"
VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z")
FILES = ("release-manifest.json", "docker-compose.selfhost.yml", ".env.example", "selfhost-setup.sh", "CHANGELOG.md")
GIB = 1024 ** 3
LEGACY_MATRIX_SERVICES = {"matrix-db", "matrix-synapse", "matrix-redis", "matrix-element",
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"}
class UpdateError(Exception):
pass
class PlanChangedError(UpdateError):
pass
def now():
return datetime.datetime.now(datetime.timezone.utc).isoformat()
def sha(data):
return hashlib.sha256(data).hexdigest()
def atomic(path, data):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
temp = path.with_name(path.name + ".tmp-" + uuid.uuid4().hex)
try:
with temp.open("xb") as stream:
os.chmod(temp, 0o600)
stream.write(data if isinstance(data, bytes) else data.encode())
stream.flush()
os.fsync(stream.fileno())
os.replace(temp, path)
fd = os.open(path.parent, os.O_RDONLY)
try:
os.fsync(fd)
finally:
os.close(fd)
finally:
temp.unlink(missing_ok=True)
def write_json(path, data):
atomic(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n")
def read_json(path, default=None):
return json.loads(Path(path).read_text()) if Path(path).exists() else default
def set_version(data, version):
# Never source .env as a shell program. Preserve every other byte/setting.
value = data.decode()
pattern = r"(?m)^(?:export\s+)?FEDEO_VERSION=[^\r\n]*"
if re.search(pattern, value):
return re.sub(pattern, "FEDEO_VERSION=" + version, value).encode()
return (value + ("" if value.endswith("\n") else "\n") + "FEDEO_VERSION=" + version + "\n").encode()
class Https:
def __init__(self, token=""):
self.token = token
def get(self, url, limit=8 * 1024 * 1024):
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args):
return None
opener = urllib.request.build_opener(NoRedirect)
for _ in range(6):
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or parsed.username or parsed.password:
raise UpdateError("Download benötigt eine HTTPS-Adresse ohne Zugangsdaten.")
headers = {"Accept": "application/json", "User-Agent": "FEDEO-Updater/1"}
if self.token and parsed.netloc == urllib.parse.urlsplit(REPOSITORY).netloc:
headers["Authorization"] = "token " + self.token
try:
with opener.open(urllib.request.Request(url, headers=headers), timeout=30) as response:
data = response.read(limit + 1)
if len(data) > limit:
raise UpdateError("Release-Datei überschreitet die erlaubte Größe.")
return data
except urllib.error.HTTPError as error:
if error.code in (301, 302, 303, 307, 308) and error.headers.get("Location"):
url = urllib.parse.urljoin(url, error.headers["Location"])
continue
raise UpdateError(f"Download fehlgeschlagen: HTTP {error.code}.") from None
except (OSError, urllib.error.URLError):
raise UpdateError("HTTPS-Anfrage fehlgeschlagen; Verbindung und Zertifikat prüfen.") from None
raise UpdateError("Zu viele Download-Weiterleitungen.")
def json(self, url):
try:
return json.loads(self.get(url))
except (ValueError, UnicodeError):
raise UpdateError("Ungültige JSON-Antwort.") from None
class Releases:
def __init__(self, http):
self.http = http
self.api = REPOSITORY.replace("/flfeders/FEDEO", "/api/v1/repos/flfeders/FEDEO")
def download(self, requested, destination):
if requested:
version = requested.removeprefix("v")
if not VERSION.fullmatch(version):
raise UpdateError("Eine stabile Version wie 3.1.0 angeben.")
release = self.http.json(self.api + "/releases/tags/v" + version)
else:
releases = []
for page in range(1, 101):
batch = self.http.json(self.api + f"/releases?limit=50&page={page}")
releases.extend(r for r in batch if not r.get("draft") and not r.get("prerelease")
and VERSION.fullmatch(r.get("tag_name", "").removeprefix("v")))
if len(batch) < 50:
break
if not releases:
raise UpdateError("Kein veröffentlichter stabiler Release gefunden.")
release = max(releases, key=lambda r: tuple(map(int, r["tag_name"].removeprefix("v").split("."))))
version = release["tag_name"].removeprefix("v")
if release.get("draft") or release.get("prerelease") or release.get("tag_name") != "v" + version:
raise UpdateError("Nur veröffentlichte stabile Releases sind erlaubt.")
assets = self.http.json(self.api + f'/releases/{int(release["id"])}/assets')
urls = {}
for asset in assets:
name = asset.get("name")
if name in (*FILES, "SHA256SUMS"):
url = asset.get("browser_download_url", "")
if name in urls or urllib.parse.urlsplit(url).netloc != urllib.parse.urlsplit(REPOSITORY).netloc:
raise UpdateError("Doppelte Release-Datei oder unerwartete Download-Herkunft.")
urls[name] = url
if any(name not in urls for name in (*FILES, "SHA256SUMS")):
raise UpdateError("Release enthält nicht alle benötigten Betriebsdateien.")
checksum_data = self.http.get(urls["SHA256SUMS"])
sums = {}
for line in checksum_data.decode().splitlines():
match = re.fullmatch(r"([a-f0-9]{64}) ([^/\\]+)", line)
if not match or match[2] in sums:
raise UpdateError("Ungültige oder doppelte Prüfsummen.")
sums[match[2]] = match[1]
for name in FILES:
data = self.http.get(urls[name])
if sha(data) != sums.get(name):
raise UpdateError(f"Prüfsumme stimmt nicht: {name}.")
atomic(destination / name, data)
atomic(destination / "SHA256SUMS", checksum_data)
manifest = read_json(destination / "release-manifest.json")
tag = self.http.json(self.api + "/tags/v" + version)
if (manifest.get("schemaVersion") != 1 or manifest.get("version") != version
or manifest.get("tag") != "v" + version or manifest.get("repository") != REPOSITORY
or not re.fullmatch(r"[a-f0-9]{40}", manifest.get("commit", ""))
or manifest["commit"] != tag.get("commit", {}).get("sha")):
raise UpdateError("Release-Manifest, Git-Tag und Version passen nicht zusammen.")
for name in ("backend", "frontend"):
images = [i for i in manifest.get("images", []) if i.get("name") == name]
if (len(images) != 1 or images[0].get("repository") != REGISTRY + "/" + name
or not re.fullmatch(r"sha256:[a-f0-9]{64}", images[0].get("digest", ""))):
raise UpdateError(f"Ungültiger Image-Digest für {name}.")
return manifest
class Runner:
def __init__(self, log):
self.log = log
# Compose interpolation must use the installation's .env, not host shell settings.
allowed = {"PATH", "HOME", "LANG", "LC_ALL", "DOCKER_CONFIG", "DOCKER_HOST", "DOCKER_CONTEXT",
"DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "XDG_RUNTIME_DIR", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"}
self.env = {k: v for k, v in os.environ.items() if k in allowed}
def run(self, args, *, output=None, input_file=None, timeout=1800):
with contextlib.ExitStack() as stack:
log = stack.enter_context(self.log.open("ab"))
target = stack.enter_context(Path(output).open("wb")) if output else subprocess.PIPE
source = stack.enter_context(Path(input_file).open("rb")) if input_file else subprocess.DEVNULL
# Do not log argv/environment: Compose commands may contain secrets.
log.write(f"{now()} Start: {args[0]}\n".encode())
log.flush()
proc = subprocess.Popen(args, stdout=target, stderr=log, stdin=source, env=self.env,
start_new_session=True, close_fds=True)
try:
stdout, _ = proc.communicate(timeout=timeout)
except BaseException:
with contextlib.suppress(ProcessLookupError):
os.killpg(proc.pid, signal.SIGTERM)
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
with contextlib.suppress(ProcessLookupError):
os.killpg(proc.pid, signal.SIGKILL)
proc.wait()
raise
if proc.returncode:
if stdout:
log.write(stdout)
raise UpdateError(f"{args[0]} fehlgeschlagen (Exit {proc.returncode}); geschütztes commands.log prüfen.")
return (stdout or b"").decode()
class Updater:
def __init__(self, root, *, http=None, runner_factory=Runner):
self.root = Path(root).resolve()
if not self.root.is_dir() or (self.root / ".git").exists():
raise UpdateError("Ein bestehendes Selfhost-Betriebsverzeichnis ohne Git-Checkout angeben.")
self.home = self.root / ".updates"
self.home.mkdir(mode=0o700, exist_ok=True)
if self.home.is_symlink():
raise UpdateError(".updates darf kein symbolischer Link sein.")
os.chmod(self.home, 0o700)
self.http = http or Https(os.environ.get("FEDEO_RELEASE_TOKEN", ""))
self.releases = Releases(self.http)
self.runner_factory = runner_factory
self.state = None
@contextlib.contextmanager
def lock(self):
with (self.home / "lock").open("a") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise UpdateError("Ein Update oder eine Prüfung läuft bereits.") from None
# Children never inherit the lock; the kernel releases it after process death.
yield
def status(self):
state = read_json(self.home / "status.json", {"status": "idle"})
try:
with self.lock():
state = read_json(self.home / "status.json", {"status": "idle"})
if state.get("status") == "running":
state.update(status="interrupted", error="Update-Prozess wurde unterbrochen.", updatedAt=now())
write_json(self.home / "status.json", state)
write_json(Path(state["job"]) / "status.json", state)
except UpdateError:
pass
return state
def save(self, phase=None, **values):
self.state.update(values, updatedAt=now())
if phase:
self.state["phase"] = phase
write_json(self.home / "status.json", self.state)
write_json(self.job / "status.json", self.state)
with (self.job / "events.jsonl").open("a") as stream:
stream.write(json.dumps({"time": now(), "phase": self.state["phase"], "status": self.state["status"]}) + "\n")
def compose(self, candidate, *args, **kwargs):
prefix = ["docker", "compose", "--project-directory", str(self.root)]
if getattr(self, "project", None):
prefix += ["--project-name", self.project]
prefix += ["--env-file", str(self.job / "target.env" if candidate else self.root / ".env"),
"-f", str(self.job / "target.compose.yml" if candidate else self.root / "docker-compose.yml")]
if self.override:
prefix += ["-f", str(self.override)]
return self.runner.run(prefix + list(args), **kwargs)
def config(self, candidate):
return json.loads(self.compose(candidate, "config", "--format", "json"))
def inspect(self, name):
return json.loads(self.runner.run(["docker", "inspect", name]))[0]
def check_space(self, required, directory):
if shutil.disk_usage(directory).free < required:
raise UpdateError(f"Zu wenig freier Speicher auf {directory}: mindestens {required // GIB + 1} GiB erforderlich.")
def prepare(self, version, *, allow_current=False):
for name in (".env", "docker-compose.yml"):
path = self.root / name
if not path.is_file() or path.is_symlink():
raise UpdateError(f"{name} fehlt oder ist ein symbolischer Link.")
if any((self.root / name).exists() for name in ("compose.yaml", "compose.yml")):
raise UpdateError("Mehrdeutige Compose-Dateien im Betriebsverzeichnis.")
overrides = [self.root / name for name in ("docker-compose.override.yml", "docker-compose.override.yaml")
if (self.root / name).exists()]
if len(overrides) > 1 or any(p.is_symlink() for p in overrides):
raise UpdateError("Genau eine reguläre Compose-Override-Datei verwenden.")
self.override = overrides[0] if overrides else None
self.runner = self.runner_factory(self.job / "commands.log")
compose_version = self.runner.run(["docker", "compose", "version", "--short"]).strip().lstrip("v")
parts = re.match(r"(\d+)\.(\d+)", compose_version)
if not parts or tuple(map(int, parts.groups())) < (2, 24):
raise UpdateError("Docker Compose ab Version 2.24 wird benötigt.")
info = json.loads(self.runner.run(["docker", "info", "--format", "{{json .}}"] ))
if info.get("OSType") != "linux" or info.get("Architecture") not in ("x86_64", "amd64"):
raise UpdateError("Diese Releases unterstützen ausschließlich Linux/amd64.")
endpoint = self.runner.run(["docker", "context", "inspect", "--format", "{{.Endpoints.docker.Host}}"] ).strip()
if not os.environ.get("DOCKER_HOST", endpoint).startswith("unix://"):
raise UpdateError("Der Updater muss direkt auf dem lokalen Docker-Host laufen.")
self.check_space(5 * GIB, info["DockerRootDir"])
self.current = self.config(False)
self.project = self.current["name"]
self.manifest = self.releases.download(version, self.job)
self.target = self.manifest["version"]
self.save("validating", version=self.target, revision=self.manifest["commit"])
old_version = self.current["services"]["backend"].get("environment", {}).get("FEDEO_VERSION", "dev")
update_available = not VERSION.fullmatch(old_version) or tuple(map(int, old_version.split("."))) < tuple(map(int, self.target.split(".")))
if not allow_current and not update_available:
raise UpdateError("Zielversion muss neuer sein; Downgrades und erneute Installation sind gesperrt.")
previous = read_json(self.home / "installed.json", {})
write_json(self.job / "original-installed.json", previous)
previous_manifest = previous.get("manifest", {})
old_migrations = {m["path"]: m["sha256"] for m in previous_manifest.get("migrations", [])}
new_migrations = {m["path"]: m["sha256"] for m in self.manifest.get("migrations", [])}
if any(new_migrations.get(name) != checksum for name, checksum in old_migrations.items()):
raise UpdateError("Bereits ausgelieferte Migrationen wurden verändert oder entfernt.")
original = (self.root / "docker-compose.yml").read_bytes()
self.managed = sha(original) == previous.get("composeSha256")
compose = (self.job / "docker-compose.selfhost.yml").read_text()
self.images = {}
for name in ("backend", "frontend"):
image = next(i for i in self.manifest["images"] if i["name"] == name)
self.images[name] = image["repository"] + "@" + image["digest"]
pattern = r"(?m)^(\s+image:\s*)" + re.escape(image["repository"]) + r":[^\r\n]+$"
compose, count = re.subn(pattern, lambda m: m[1] + self.images[name], compose)
if count != 1:
raise UpdateError(f"Release-Compose enthält kein eindeutiges {name}-Image.")
atomic(self.job / "target.compose.yml", compose)
atomic(self.job / "target.env", set_version((self.root / ".env").read_bytes(), self.target))
diff = "".join(difflib.unified_diff(original.decode().splitlines(True), compose.splitlines(True),
fromfile="installed/docker-compose.yml", tofile="release/docker-compose.yml"))
atomic(self.job / "compose.diff", diff)
self.candidate = self.config(True)
self.validate_configs()
ids = self.compose(False, "ps", "--all", "--quiet").split()
self.containers = [self.inspect(container) for container in ids]
write_json(self.job / "original-containers.json", self.containers)
self.retired_containers = [c for c in self.containers
if c["Config"]["Labels"].get("com.docker.compose.service") in self.retired_services]
if any(c.get("HostConfig", {}).get("AutoRemove") for c in self.retired_containers):
raise UpdateError("Matrix-Container mit automatischer Löschung können nicht zur Wiederherstellung erhalten werden.")
for container in self.retired_containers:
name = container["Config"]["Labels"]["com.docker.compose.service"]
expected = {v["target"]: v["source"] for v in self.current["services"][name].get("volumes", [])}
actual = {v["Destination"]: v["Source"] for v in container.get("Mounts", []) if v["Type"] == "bind"}
if actual != expected:
raise UpdateError(f"{name}: Tatsächliche Matrix-Datenpfade weichen von der Compose-Konfiguration ab.")
self.save(retiredServices=self.retired_services,
retiredContainerIds=[c["Id"] for c in self.retired_containers])
if any(c["State"]["Running"] and c["Config"]["Labels"].get("com.docker.compose.oneoff", "False").lower() == "true" for c in self.containers):
raise UpdateError("Ein Einmalcontainer läuft noch; dessen Arbeit zuerst abschließen.")
self.running = [c["Config"]["Labels"]["com.docker.compose.service"] for c in self.containers
if c["State"]["Running"] and c["Config"]["Labels"].get("com.docker.compose.oneoff", "False").lower() != "true"]
if not {"backend", "frontend", "db", "minio"}.issubset(self.running):
raise UpdateError("Backend, Frontend, Datenbank und MinIO müssen vor dem Update laufen.")
if not set(self.running).issubset(self.current["services"]):
raise UpdateError("Nicht konfigurierte Container dieses Compose-Projekts laufen noch.")
self.before = {str(p): sha(p.read_bytes()) for p in
[self.root / ".env", self.root / "docker-compose.yml", *([self.override] if self.override else [])]}
database_size = int(self.compose(False, "exec", "-T", "db", "sh", "-ec",
'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT pg_database_size(current_database())"').strip())
minio_size = int(self.runner.run(["du", "-sb", str(self.root / "minio")]).split()[0])
self.backup_size = database_size * 2 + minio_size + GIB
if self.matrix_backup_paths:
self.backup_size += int(self.runner.run(["du", "-sb", str(self.root / "matrix")]).split()[0])
self.check_space(self.backup_size, self.home)
plan = {"version": self.target, "revision": self.manifest["commit"], "previousVersion": old_version,
"images": self.images, "composeDiff": str(self.job / "compose.diff"),
"releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed,
"estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"]),
"retiredServices": self.retired_services, "retainedDataPaths": self.matrix_backup_paths,
"updateAvailable": update_available,
# Kept in private job records; the service never exposes configuration fingerprints.
"approvalFingerprint": sha(json.dumps({"manifest": self.manifest, "configuration": self.before,
"targetCompose": sha((self.job / "target.compose.yml").read_bytes())}, sort_keys=True).encode())}
write_json(self.job / "plan.json", plan)
return plan
def validate_configs(self):
old, new = self.current["services"], self.candidate["services"]
required = {"backend", "frontend", "db", "minio", "createbuckets", "traefik", "node-exporter"}
differences = []
for label, services in (("Bestehende Konfiguration", old), ("Zielkonfiguration", new)):
missing, additional = required - set(services), set(services) - required
if services is old:
additional -= LEGACY_MATRIX_SERVICES
details = []
if missing:
details.append("fehlend: " + ", ".join(sorted(missing)))
if additional:
details.append("zusätzlich: " + ", ".join(sorted(additional)))
if details:
differences.append(label + " (" + "; ".join(details) + ")")
if differences:
raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalen Daten und MinIO. "
+ ". ".join(differences) + ". Zusätzliche Dienste benötigen Unterstützung für Erhalt, Backup und Wiederherstellung.")
self.retired_services = sorted(set(old) & LEGACY_MATRIX_SERVICES)
self.matrix_backup_paths = []
self.validate_legacy_matrix()
for name in required:
if old[name].get("profiles") or new[name].get("profiles") or new[name].get("build"):
raise UpdateError("Build-Kontexte und Compose-Profile werden nicht unterstützt.")
old_mounts, new_mounts = old[name].get("volumes", []), new[name].get("volumes", [])
# One-time bootstrap of the constrained updater API; never relax data-mount checks.
control_mount = {"type": "bind", "source": "/run/fedeo-updater", "target": "/run/fedeo-updater",
"read_only": True, "bind": {}}
control_variants = (control_mount, {**control_mount, "bind": {"create_host_path": True}})
if name == "backend" and not any(v.get("target") == "/run/fedeo-updater" for v in old_mounts):
new_mounts = [v for v in new_mounts if v not in control_variants]
if old_mounts != new_mounts:
raise UpdateError(f"Datenpfade/Mounts von {name} würden verändert; zuerst manuell migrieren.")
if old[name].get("container_name") != new[name].get("container_name"):
raise UpdateError(f"Container-Identität von {name} würde verändert.")
if self.current.get("networks") != self.candidate.get("networks"):
raise UpdateError("Docker-Netzwerke würden verändert.")
for name, target, directory in (("db", "/var/lib/postgresql/data", "postgres"), ("minio", "/data", "minio")):
volumes = new[name].get("volumes", [])
if len(volumes) != 1 or volumes[0].get("type") != "bind" or volumes[0].get("target") != target or volumes[0].get("source") != str(self.root / directory):
raise UpdateError(f"{name} benötigt das lokale Standard-Datenverzeichnis.")
if (self.root / directory).is_symlink() or not (self.root / directory).is_dir():
raise UpdateError(f"Ungültiges Datenverzeichnis: {directory}.")
if old[name].get("environment") != new[name].get("environment"):
raise UpdateError(f"Zugangsdaten/Konfiguration von {name} würden verändert.")
keys = ("POSTGRES_DB", "POSTGRES_USER", "POSTGRES_PASSWORD") if name == "db" else ("MINIO_ROOT_USER", "MINIO_ROOT_PASSWORD")
if any(not new[name].get("environment", {}).get(key) for key in keys):
raise UpdateError(f"Pflichteinstellungen von {name} fehlen.")
if old["db"]["image"] != new["db"]["image"]:
raise UpdateError("Ein Wechsel der PostgreSQL-Version benötigt eine separate Datenbankmigration.")
for name in ("backend", "frontend"):
healthcheck = new[name].get("healthcheck", {})
if new[name]["image"] != self.images[name] or healthcheck.get("disable") or not healthcheck.get("test") or healthcheck["test"] == ["NONE"]:
raise UpdateError(f"{name}: Release-Digest oder Healthcheck wurde überschrieben.")
for name in ("minio", "createbuckets"):
if not re.search(r"@sha256:[a-f0-9]{64}$", new[name]["image"]):
raise UpdateError("MinIO und mc müssen auf feste Digests gepinnt sein.")
env = new["backend"].get("environment", {})
for key in ("DATABASE_URL", "S3_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "ENCRYPTION_KEY", "COOKIE_SECRET", "JWT_SECRET"):
if not env.get(key) or env[key] != old["backend"].get("environment", {}).get(key):
raise UpdateError(f"Backend-Einstellung {key} fehlt oder würde verändert.")
if (env.get("FEDEO_FILE_BACKEND", "s3") != "s3" or old["backend"].get("environment", {}).get("FEDEO_FILE_BACKEND", "s3") != "s3" or urllib.parse.urlsplit(env["S3_ENDPOINT"]).hostname != "minio"
or urllib.parse.urlsplit(env["DATABASE_URL"]).hostname != "db"):
raise UpdateError("Externe Datenbanken/Dateispeicher benötigen einen eigenen Backup-Adapter.")
database = urllib.parse.urlsplit(env["DATABASE_URL"])
db_env = new["db"]["environment"]
if (urllib.parse.unquote(database.path.lstrip("/")) != db_env["POSTGRES_DB"]
or urllib.parse.unquote(database.username or "") != db_env["POSTGRES_USER"]
or urllib.parse.unquote(database.password or "") != db_env["POSTGRES_PASSWORD"]
or database.port not in (None, 5432)):
raise UpdateError("DATABASE_URL muss auf die gesicherte lokale PostgreSQL-Datenbank zeigen.")
url = new["frontend"].get("environment", {}).get("NUXT_PUBLIC_API_BASE", "")
parts = urllib.parse.urlsplit(url)
if parts.scheme != "https" or not parts.hostname or parts.username or parts.password or parts.path.rstrip("/") != "/backend" or parts.query or parts.fragment:
raise UpdateError("Die öffentliche API-Adresse muss https://DOMAIN/backend entsprechen.")
self.public = urllib.parse.urlunsplit((parts.scheme, parts.netloc, "", "", ""))
for name, service in new.items():
for key, value in service.get("environment", {}).items():
if value is None or (value == "" and key in ("DOMAIN", "POSTGRES_PASSWORD", "COOKIE_SECRET", "JWT_SECRET", "ENCRYPTION_KEY")):
raise UpdateError(f"Fehlende Umgebungsvariable: {name}.{key}.")
def validate_legacy_matrix(self):
"""Only retire the known, local legacy stack; never silently drop arbitrary services."""
if not self.retired_services:
return
matrix_root = self.root / "matrix"
for name in self.retired_services:
service = self.current["services"][name]
if any(service.get(key) for key in ("profiles", "build", "volumes_from", "configs", "secrets")):
raise UpdateError(f"{name}: Abweichende Matrix-Konfiguration benötigt eine manuelle Stilllegung.")
volumes = service.get("volumes", [])
for volume in volumes:
source = Path(volume.get("source", ""))
if (volume.get("type") != "bind" or not source.is_relative_to(matrix_root)
or source.resolve() != source or not source.exists()):
raise UpdateError(f"{name}: Matrix-Daten müssen ohne symbolische Links unter {matrix_root} liegen.")
expected = {"matrix-db": ("postgres", "/var/lib/postgresql/data"),
"matrix-synapse": ("synapse", "/data")}.get(name)
if expected and (not (matrix_root / expected[0]).is_dir() or not any(
v.get("source") == str(matrix_root / expected[0]) and v.get("target") == expected[1] for v in volumes)):
raise UpdateError(f"{name}: Das lokale Standard-Matrix-Datenverzeichnis fehlt.")
if matrix_root.exists() or matrix_root.is_symlink():
if matrix_root.is_symlink() or not matrix_root.is_dir():
raise UpdateError("Das Matrix-Datenverzeichnis muss ein reguläres lokales Verzeichnis sein.")
# A cold PostgreSQL backup must include WAL and tablespaces, not external symlink targets.
if any(path.is_symlink() for path in matrix_root.rglob("*")):
raise UpdateError("Symbolische Links in Matrix-Daten benötigen eine manuelle Sicherung/Stilllegung.")
self.matrix_backup_paths = ["matrix"]
def verify_matrix_stopped(self, *, retired=False):
for container_id in self.state.get("retiredContainerIds", []):
container = self.inspect(container_id)
state = container.get("State", {})
if state.get("Running") or state.get("Restarting") or state.get("Paused"):
raise UpdateError("Ein alter Matrix-Container läuft noch; Stilllegung nicht abgeschlossen.")
if retired and container.get("HostConfig", {}).get("RestartPolicy", {}).get("Name") != "no":
raise UpdateError("Ein alter Matrix-Container könnte automatisch neu starten; Stilllegung nicht abgeschlossen.")
def backup(self):
self.verify_matrix_stopped()
self.validate_legacy_matrix()
directory = self.job / "backup"
directory.mkdir(mode=0o700)
for name in (".env", "docker-compose.yml", ".env.example", "scripts/selfhost-setup.sh"):
if (self.root / name).is_file():
atomic(directory / "config" / name, (self.root / name).read_bytes())
if self.override:
atomic(directory / "config" / self.override.name, self.override.read_bytes())
write_json(directory / "containers.json", self.containers)
self.compose(False, "exec", "-T", "db", "sh", "-ec",
'exec pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom', output=directory / "database.dump")
self.compose(False, "exec", "-T", "db", "pg_restore", "--list", input_file=directory / "database.dump")
self.compose(False, "exec", "-T", "db", "sh", "-ec",
'exec pg_dumpall -U "$POSTGRES_USER" --globals-only', output=directory / "globals.sql")
paths = ["minio", *self.matrix_backup_paths]
if (self.root / "traefik/letsencrypt").is_dir():
paths.append("traefik/letsencrypt")
self.runner.run(["tar", "-cpf", str(directory / "files.tar"), "-C", str(self.root), *paths], timeout=7200)
self.runner.run(["tar", "-tf", str(directory / "files.tar")], output=directory / "files.list", timeout=7200)
checksums = {}
for path in sorted(directory.rglob("*")):
if path.is_file():
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
os.fsync(stream.fileno())
checksums[str(path.relative_to(directory))] = digest.hexdigest()
self.verify_matrix_stopped()
write_json(directory / "complete.json", {"createdAt": now(), "database": "database.dump", "files": "files.tar",
"retiredServices": self.retired_services, "retainedContainerIds": self.state["retiredContainerIds"],
"matrixDataPaths": self.matrix_backup_paths, "sha256": checksums})
self.save("backed_up", backup=str(directory))
def verify(self, version, revision=None):
# Traefik may need a moment to discover newly created healthy containers.
for attempt in range(6):
try:
for path in ("/backend/health", "/api/health"):
value = self.http.json(self.public + path)
if value.get("status") != "ok" or value.get("product") != "FEDEO" or value.get("version") != version or (revision and value.get("revision") != revision):
raise UpdateError("Öffentliche Funktionsprüfung meldet eine falsche Version oder einen Fehler.")
return
except UpdateError:
if attempt == 5:
raise
time.sleep(2)
def apply(self, accept_compose):
if not self.managed and not accept_compose:
raise UpdateError("Compose-Datei ist noch nicht verwaltet oder wurde bearbeitet. compose.diff prüfen, Anpassungen in die Override-Datei übertragen und --accept-compose-replacement verwenden.")
self.save("pulling")
self.compose(True, "pull", timeout=3600)
self.check_space(self.backup_size, self.home)
if any(sha(Path(path).read_bytes()) != value for path, value in self.before.items()):
raise UpdateError("Konfiguration wurde während der Vorbereitung verändert; erneut prüfen.")
self.save("maintenance", recoveryRequired=True, maintenance=True, runningServices=self.running)
# Stop every writer and public entry point. The database stays up for pg_dump.
self.compose(False, "stop", "--timeout", "60", *[s for s in self.running if s != "db"])
self.backup()
if any(sha(Path(path).read_bytes()) != value for path, value in self.before.items()):
raise UpdateError("Konfiguration wurde während des Backups verändert; erneut prüfen.")
# From here recovery must be explicit: migrations or infrastructure can alter persisted data.
self.save("applying", mutationStarted=True)
if self.retired_containers:
self.save("retiring_matrix")
self.verify_matrix_stopped()
# Keep containers, writable layers and volumes for recovery; prevent restart on host reboot.
for container in self.retired_containers:
self.runner.run(["docker", "update", "--restart=no", container["Id"]])
self.verify_matrix_stopped(retired=True)
atomic(self.root / "docker-compose.yml", (self.job / "target.compose.yml").read_bytes())
atomic(self.root / ".env", (self.job / "target.env").read_bytes())
atomic(self.root / ".env.example", (self.job / ".env.example").read_bytes())
atomic(self.root / "scripts/selfhost-setup.sh", (self.job / "selfhost-setup.sh").read_bytes())
os.chmod(self.root / "scripts/selfhost-setup.sh", 0o700)
self.compose(True, "up", "-d", "--no-deps", "--wait", "--wait-timeout", "180", "db", "minio", "node-exporter")
self.compose(True, "run", "--rm", "--no-deps", "createbuckets")
self.save("migrating", migrationContainer="fedeo-update-" + self.job.name)
self.compose(True, "run", "--rm", "--no-deps", "--name", self.state["migrationContainer"],
"-e", "FEDEO_RUN_MIGRATIONS=false", "--entrypoint", "npm", "backend", "run", "migrate")
self.save("starting")
self.compose(True, "up", "-d", "--wait", "--wait-timeout", "600", timeout=900)
self.save("verifying")
self.verify_matrix_stopped(retired=True)
for name, image in self.images.items():
container_id = self.compose(True, "ps", "--quiet", name).strip()
container = self.inspect(container_id)
image_id = self.runner.run(["docker", "image", "inspect", image, "--format", "{{.Id}}"] ).strip()
if container["Image"] != image_id or container.get("State", {}).get("Health", {}).get("Status") != "healthy":
raise UpdateError(f"{name} verwendet nicht das erwartete gesunde Image.")
self.verify(self.target, self.manifest["commit"])
write_json(self.home / "installed.json", {"manifest": self.manifest, "composeSha256": sha((self.root / "docker-compose.yml").read_bytes()), "installedAt": now()})
self.save("complete", status="succeeded", recoveryRequired=False, maintenance=False)
def execute(self, command, version=None, accept_compose=False, *, expected_fingerprint=None, request_id=None):
with self.lock():
previous = read_json(self.home / "status.json", {})
if previous.get("recoveryRequired"):
raise UpdateError("Ein vorheriges Update benötigt Wiederherstellung. status und docs/selfhost-updates.md prüfen; danach resolve ausführen.")
self.job = self.home / (datetime.datetime.now().strftime("%Y%m%d-%H%M%S-") + uuid.uuid4().hex[:8])
self.job.mkdir(mode=0o700)
self.state = {"status": "running", "phase": "preparing", "job": str(self.job), "command": command,
"startedAt": now(), "recoveryRequired": False, "mutationStarted": False}
if request_id:
self.state["requestId"] = request_id
self.save()
try:
plan = self.prepare(version, allow_current=command == "check")
if expected_fingerprint is not None and plan["approvalFingerprint"] != expected_fingerprint:
raise PlanChangedError("Release oder Konfiguration haben sich seit der Prüfung geändert; erneut prüfen.")
if command == "check":
self.save("checked", status="checked")
else:
self.apply(accept_compose)
return {**self.state, "plan": plan}
except BaseException as error:
if self.state.get("maintenance") and not self.state.get("mutationStarted"):
try:
self.compose(False, "start", *self.running)
self.save(recoveryRequired=False, maintenance=False)
except BaseException:
pass
elif self.state.get("mutationStarted"):
try:
self.compose(True, "stop", "--timeout", "60", "backend", "frontend", "traefik")
except BaseException:
pass
self.save(status="interrupted" if isinstance(error, (KeyboardInterrupt, SystemExit)) else "failed",
error=str(error) if isinstance(error, UpdateError) else type(error).__name__)
raise
def resolve(self, version):
if not version or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.+-]{0,100}", version):
raise UpdateError("Die manuell wiederhergestellte Version mit --version angeben.")
with self.lock():
state = read_json(self.home / "status.json", {})
if not state.get("recoveryRequired"):
raise UpdateError("Kein offener Wiederherstellungsfall.")
self.job = Path(state["job"])
self.state = state
self.runner = self.runner_factory(self.job / "commands.log")
self.override = next((self.root / name for name in ("docker-compose.override.yml", "docker-compose.override.yaml") if (self.root / name).exists()), None)
current = self.config(False)
self.public = current["services"]["frontend"]["environment"]["NUXT_PUBLIC_API_BASE"].removesuffix("/backend")
if state.get("migrationContainer"):
remaining = self.runner.run(["docker", "ps", "--all", "--quiet", "--filter", "name=^/" + state["migrationContainer"] + "$"]).strip()
if remaining:
raise UpdateError("Den verbliebenen Migrationscontainer zuerst prüfen und manuell entfernen.")
# Older releases have /version and /health, but no frontend health endpoint.
version_info = self.http.json(self.public + "/backend/version")
if version_info.get("product") != "FEDEO" or version_info.get("version") != version:
raise UpdateError("Die wiederhergestellte Backend-Version stimmt nicht.")
if self.http.json(self.public + "/backend/health").get("status") != "ok":
raise UpdateError("Das wiederhergestellte Backend ist nicht erreichbar.")
self.http.get(self.public + "/")
original_images = {c["Config"]["Labels"]["com.docker.compose.service"]: c["Image"]
for c in read_json(self.job / "original-containers.json", [])}
manifest = read_json(self.job / "release-manifest.json")
for name in ("backend", "frontend", "db", "minio"):
container_id = self.compose(False, "ps", "--quiet", name).strip()
container = self.inspect(container_id) if container_id else {}
container_state = container.get("State", {})
if not container_state.get("Running") or container_state.get("Health", {}).get("Status") not in (None, "healthy"):
raise UpdateError(f"{name} läuft noch nicht.")
if name in ("backend", "frontend"):
if version == manifest["version"]:
image = next(i for i in manifest["images"] if i["name"] == name)
expected = self.runner.run(["docker", "image", "inspect", image["repository"] + "@" + image["digest"], "--format", "{{.Id}}"] ).strip()
else:
expected = original_images.get(name)
if not expected or container.get("Image") != expected:
raise UpdateError(f"{name} entspricht weder dem gewählten Zielrelease noch dem gesicherten Originalstand.")
if version == manifest["version"]:
self.verify_matrix_stopped(retired=True)
self.verify(version, manifest["commit"])
write_json(self.home / "installed.json", {"manifest": manifest,
"composeSha256": sha((self.job / "target.compose.yml").read_bytes()), "installedAt": now()})
else:
for original in read_json(self.job / "original-containers.json", []):
if original.get("Id") not in state.get("retiredContainerIds", []):
continue
name = original["Config"]["Labels"]["com.docker.compose.service"]
container_id = self.compose(False, "ps", "--all", "--quiet", name).strip()
restored = self.inspect(container_id) if container_id else {}
if (restored.get("Image") != original["Image"]
or restored.get("State", {}).get("Running") != original["State"]["Running"]
or (original["State"]["Running"] and restored.get("State", {}).get("Health", {}).get("Status") not in (None, "healthy"))
or restored.get("HostConfig", {}).get("RestartPolicy") != original["HostConfig"]["RestartPolicy"]):
raise UpdateError("Die ursprünglichen Matrix-Container und Neustartrichtlinien müssen zuerst wiederhergestellt werden.")
previous = read_json(self.job / "original-installed.json", {})
if previous:
write_json(self.home / "installed.json", previous)
else:
(self.home / "installed.json").unlink(missing_ok=True)
self.save(status="recovered", phase="resolved", recoveryRequired=False, maintenance=False, recoveredVersion=version)
return self.state
def main(argv=None):
if sys.version_info < (3, 10):
print("Python ab Version 3.10 wird benötigt.", file=sys.stderr)
return 1
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("command", choices=("check", "apply", "status", "resolve"))
parser.add_argument("--directory", default=os.environ.get("FEDEO_DIR", "/opt/fedeo"))
parser.add_argument("--version", help="Zielversion, z. B. 3.1.0; check wählt sonst den neuesten stabilen Release")
parser.add_argument("--accept-compose-replacement", action="store_true", help="Zuvor geprüften Austausch einer nicht verwalteten Compose-Datei akzeptieren")
args = parser.parse_args(argv)
if args.command == "apply" and not args.version:
parser.error("apply benötigt eine explizite --version; zuerst check ausführen")
os.umask(0o077)
signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
try:
updater = Updater(args.directory)
if args.command == "status":
result = updater.status()
elif args.command == "resolve":
result = updater.resolve(args.version)
else:
result = updater.execute(args.command, args.version, args.accept_compose_replacement)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (UpdateError, OSError, ValueError, KeyError, subprocess.TimeoutExpired) as error:
print(str(error) if isinstance(error, UpdateError) else f"Update abgebrochen: {type(error).__name__}; geschütztes Statusprotokoll prüfen.", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,269 @@
import concurrent.futures
import http.client
import importlib.util
import json
from pathlib import Path
import socket
import subprocess
import tempfile
import threading
import unittest
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location('update_service', ROOT / 'scripts/selfhost-update-service.py')
s = importlib.util.module_from_spec(spec)
spec.loader.exec_module(s)
class Factory:
def __init__(self):
self.calls = []
self.started = threading.Event()
self.release = threading.Event()
self.release.set()
self.failure = None
self.adoption = False
self.available = True
self.fingerprint = 'a' * 64
def __call__(self, directory):
factory = self
class FakeUpdater(s.engine.Updater):
def execute(self, command, version, *, expected_fingerprint, request_id):
with self.lock():
factory.calls.append((command, version, expected_fingerprint, request_id))
self.job = self.home / ('job-' + request_id)
self.job.mkdir()
self.state = {'requestId': request_id, 'status': 'running', 'phase': 'validating',
'job': str(self.job), 'recoveryRequired': False}
self.save()
factory.started.set()
if not factory.release.wait(10):
raise AssertionError('test worker timed out')
if factory.failure:
self.save(status='failed')
raise factory.failure
if expected_fingerprint and expected_fingerprint != factory.fingerprint:
self.save(status='failed')
raise s.engine.PlanChangedError('private changed configuration')
plan = {'version': version or '3.2.0', 'revision': 'b' * 40,
'updateAvailable': factory.available, 'requiresComposeAdoption': factory.adoption,
'estimatedBackupBytes': 1000, 'approvalFingerprint': factory.fingerprint,
'composeDiff': '/private/path/secret.diff', 'retiredServices': ['matrix-db']}
self.save(status='checked' if command == 'check' else 'succeeded', phase='complete')
return {**self.state, 'plan': plan}
return FakeUpdater(directory)
class UnixConnection(http.client.HTTPConnection):
def __init__(self, path):
super().__init__('localhost', timeout=3)
self.path = str(path)
def connect(self):
self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
self.sock.settimeout(self.timeout)
self.sock.connect(self.path)
class ServiceTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
self.factory = Factory()
self.controller = s.Controller(self.root, updater_factory=self.factory)
self.socket = self.root / 'control.sock'
self.server = s.Server(self.socket, self.controller)
self.thread = threading.Thread(target=self.server.serve_forever, kwargs={'poll_interval': 0.01}, daemon=True)
self.thread.start()
def tearDown(self):
self.factory.release.set()
self.server.shutdown()
self.server.server_close()
self.thread.join(3)
self.controller.close()
self.temp.cleanup()
def request(self, method, path, body=None, headers=None):
connection = UnixConnection(self.socket)
connection.request(method, path, None if body is None else json.dumps(body),
headers or ({'Content-Type': 'application/json'} if body is not None else {}))
response = connection.getresponse()
result = response.status, json.loads(response.read())
self.assertEqual(response.getheader('Cache-Control'), 'no-store')
connection.close()
return result
def finish(self):
self.controller.worker.join(3)
self.assertFalse(self.controller.worker.is_alive())
def check(self, request_id='check-request-01'):
status, _ = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'})
self.assertEqual(status, 202)
self.finish()
return request_id
def test_async_job_survives_client_disconnect_and_status_stays_responsive(self):
self.factory.release.clear()
connection = UnixConnection(self.socket)
connection.request('POST', '/v1/check', json.dumps({'requestId': 'check-disconnected'}),
{'Content-Type': 'application/json'})
connection.close()
self.assertTrue(self.factory.started.wait(3))
status, result = self.request('GET', '/v1/status')
self.assertEqual(status, 200)
self.assertEqual(result['request']['status'], 'running')
self.assertEqual(result['request']['progress']['phase'], 'validating')
self.factory.release.set()
self.finish()
self.assertEqual(self.request('GET', '/v1/requests/check-disconnected')[1]['status'], 'checked')
def test_duplicate_request_is_idempotent_even_after_completion_and_restart(self):
request_id = self.check()
status, result = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'})
self.assertEqual(status, 200)
self.assertEqual(result['status'], 'checked')
self.controller.close()
self.controller = s.Controller(self.root, updater_factory=self.factory)
self.server.controller = self.controller
self.assertEqual(self.request('GET', '/v1/status')[1]['request']['requestId'], request_id)
status, _ = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'})
self.assertEqual(status, 200)
self.assertEqual(len(self.factory.calls), 1)
status, result = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.3.0'})
self.assertEqual(status, 409)
self.assertEqual(result['error']['code'], 'REQUEST_CONFLICT')
def test_concurrent_submissions_start_exactly_one_worker(self):
self.factory.release.clear()
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda i: self.request('POST', '/v1/check', {'requestId': f'parallel-{i}'}), range(2)))
self.assertEqual(sorted(r[0] for r in responses), [202, 409])
self.assertTrue(self.factory.started.wait(3))
self.assertEqual(len(self.factory.calls), 1)
def test_apply_requires_reviewed_version_and_passes_private_fingerprint(self):
body = {'requestId': 'apply-request-01', 'version': '3.2.0', 'checkRequestId': 'check-request-01'}
self.assertEqual(self.request('POST', '/v1/apply', body)[1]['error']['code'], 'CHECK_REQUIRED')
self.check()
self.assertEqual(self.request('POST', '/v1/apply', {**body, 'version': '3.3.0'})[0], 409)
self.assertEqual(self.request('POST', '/v1/apply', body)[0], 202)
self.finish()
self.assertEqual(self.factory.calls[-1], ('apply', '3.2.0', 'a' * 64, 'apply-request-01'))
self.assertEqual(self.request('GET', '/v1/requests/apply-request-01')[1]['status'], 'succeeded')
def test_unmanaged_compose_and_changed_plan_cannot_be_applied_remotely(self):
self.factory.adoption = True
self.check()
body = {'requestId': 'apply-request-01', 'version': '3.2.0', 'checkRequestId': 'check-request-01'}
self.assertEqual(self.request('POST', '/v1/apply', body)[1]['error']['code'], 'COMPOSE_REVIEW_REQUIRED')
self.factory.adoption = False
self.check('check-request-02')
self.factory.fingerprint = 'b' * 64
self.assertEqual(self.request('POST', '/v1/apply', {**body, 'checkRequestId': 'check-request-02'})[0], 202)
self.finish()
self.assertEqual(self.request('GET', '/v1/requests/apply-request-01')[1]['error']['code'], 'PLAN_CHANGED')
def test_already_current_is_a_successful_check_without_apply(self):
self.factory.available = False
self.check()
self.assertEqual(self.request('GET', '/v1/requests/check-request-01')[1]['status'], 'up_to_date')
body = {'requestId': 'apply-request-01', 'version': '3.2.0', 'checkRequestId': 'check-request-01'}
self.assertEqual(self.request('POST', '/v1/apply', body)[0], 409)
def test_restart_marks_pending_requests_interrupted_without_replaying(self):
for status in ('queued', 'running'):
s.engine.write_json(self.controller.path('abandoned-' + status),
{'requestId': 'abandoned-' + status, 'command': 'apply', 'status': status})
self.controller.close()
self.controller = s.Controller(self.root, updater_factory=self.factory)
self.server.controller = self.controller
for status in ('queued', 'running'):
record = self.request('GET', '/v1/requests/abandoned-' + status)[1]
self.assertEqual(record['status'], 'interrupted')
self.assertEqual(self.factory.calls, [])
def test_shutdown_drains_worker_and_rejects_new_work(self):
self.factory.release.clear()
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'})[0], 202)
self.assertTrue(self.factory.started.wait(3))
closing = threading.Thread(target=self.controller.close)
closing.start()
# Acquire the gate after close has entered it, without relying on a wall-clock sleep.
for _ in range(1000):
with self.controller.gate:
if self.controller.closing:
break
threading.Event().wait(0.001)
self.assertTrue(closing.is_alive())
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-02'})[1]['error']['code'], 'SHUTTING_DOWN')
self.factory.release.set()
closing.join(3)
self.assertFalse(closing.is_alive())
self.assertEqual(self.controller.request_status('check-request-01')['status'], 'checked')
def test_cli_lock_and_recovery_block_new_requests(self):
updater = self.factory(self.root)
job = updater.home / 'cli-job'
job.mkdir()
s.engine.write_json(updater.home / 'status.json', {'status': 'running', 'job': str(job)})
with updater.lock():
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'})[1]['error']['code'], 'BUSY')
s.engine.write_json(updater.home / 'status.json', {'status': 'failed', 'recoveryRequired': True})
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'})[1]['error']['code'], 'RECOVERY_REQUIRED')
def test_status_never_returns_raw_errors_paths_logs_or_configuration_fingerprints(self):
self.factory.failure = ValueError('password-secret /opt/private/.env')
self.check()
result = self.request('GET', '/v1/requests/check-request-01')[1]
self.assertEqual(result['error']['code'], 'UPDATE_FAILED')
self.assertNotIn('password-secret', json.dumps(result))
self.factory.failure = None
self.check('check-request-02')
payload = json.dumps(self.request('GET', '/v1/requests/check-request-02')[1])
self.assertNotIn('/private', payload)
self.assertNotIn('approvalFingerprint', payload)
self.assertNotIn('a' * 64, payload)
self.assertEqual(self.request('GET', '/v1/requests/../../secret')[0], 400)
self.assertEqual(self.request('GET', '/v1/logs')[0], 404)
self.assertEqual((self.controller.home / 'check-request-01.log').stat().st_mode & 0o777, 0o600)
def test_api_rejects_unsupported_commands_options_bodies_and_browser_requests(self):
for body in [[], {'requestId': 'bad/path'}, {'requestId': 'check-request-01', 'version': ';reboot'},
{'requestId': 'check-request-01', 'directory': '/etc'},
{'requestId': 'check-request-01', 'acceptComposeReplacement': True}]:
with self.subTest(body=body):
self.assertEqual(self.request('POST', '/v1/check', body)[0], 400)
self.assertEqual(self.request('POST', '/v1/resolve', {'requestId': 'resolve-request'})[0], 404)
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'},
{'Content-Type': 'application/json', 'Origin': 'https://example.test'})[0], 400)
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01', 'padding': 'x' * 5000})[0], 400)
self.assertEqual(self.factory.calls, [])
def test_second_controller_cannot_take_over_active_instance(self):
self.assertEqual(self.socket.stat().st_mode & 0o777, 0o660)
with self.assertRaisesRegex(s.engine.UpdateError, 'bereits ein Update-Dienst'):
s.Controller(self.root, updater_factory=self.factory)
class InstallerTests(unittest.TestCase):
def test_unit_render_is_read_only_and_escapes_systemd_expansions(self):
installer = ROOT / 'scripts/selfhost-update-service-install.sh'
subprocess.run(['bash', '-n', str(installer)], check=True)
result = subprocess.run(['bash', str(installer), '--print-unit', '--directory', '/opt/fedeo test%$"'],
text=True, capture_output=True, check=True)
self.assertIn('serve --directory "/opt/fedeo test%%$$\\\""', result.stdout)
self.assertIn('KillMode=mixed', result.stdout)
self.assertIn('TimeoutStopSec=infinity', result.stdout)
self.assertNotIn('@FEDEO_DIRECTORY@', result.stdout)
invalid = subprocess.run(['bash', str(installer), '--print-unit', '--directory', '/opt/fedeo\nExecStart=reboot'],
capture_output=True)
self.assertNotEqual(invalid.returncode, 0)
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,667 @@
import copy
import hashlib
import importlib.util
import json
import os
import io
from pathlib import Path
import subprocess
import tarfile
import tempfile
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3]
spec = importlib.util.spec_from_file_location('updater', ROOT / 'scripts/selfhost-update.py')
u = importlib.util.module_from_spec(spec)
spec.loader.exec_module(u)
def data(value):
return json.dumps(value).encode()
class Http:
def __init__(self):
self.manifest = {'schemaVersion': 1, 'version': '3.1.0', 'tag': 'v3.1.0', 'commit': 'a' * 40,
'repository': u.REPOSITORY, 'migrations': [],
'images': [{'name': name, 'repository': u.REGISTRY + '/' + name,
'digest': 'sha256:' + char * 64} for name, char in [('backend', 'a'), ('frontend', 'b')]]}
self.files = {name: (ROOT / {'docker-compose.selfhost.yml': 'docker-compose.selfhost.yml',
'.env.example': '.env.example', 'selfhost-setup.sh': 'scripts/selfhost-setup.sh'}[name]).read_bytes()
for name in ['docker-compose.selfhost.yml', '.env.example', 'selfhost-setup.sh']}
self.files.update({'release-manifest.json': data(self.manifest), 'CHANGELOG.md': b'New release'})
self.refresh()
self.requests = []
self.bad_health = False
self.release = {'id': 1, 'tag_name': 'v3.1.0', 'draft': False, 'prerelease': False}
self.fail_download = False
self.reported_version = '3.1.0'
def refresh(self):
self.files['SHA256SUMS'] = ''.join(hashlib.sha256(blob).hexdigest() + ' ' + name + '\n'
for name, blob in self.files.items() if name != 'SHA256SUMS').encode()
def get(self, url, **kwargs):
self.requests.append(url)
if url.endswith('/'):
return b'<!doctype html><html>FEDEO</html>'
if self.fail_download:
raise u.UpdateError('Download fehlgeschlagen')
return self.files[url.rsplit('/', 1)[-1]]
def json(self, url):
self.requests.append(url)
if url.endswith('/releases/tags/v3.1.0'):
return self.release
if '/releases?limit=' in url:
return [self.release, {'tag_name': 'v9.0.0', 'draft': True}, {'tag_name': 'v4.0.0-rc.1', 'prerelease': True}]
if url.endswith('/releases/1/assets'):
return [{'name': name, 'browser_download_url': u.REPOSITORY + '/assets/' + name} for name in self.files]
if url.endswith('/tags/v3.1.0'):
return {'commit': {'sha': 'a' * 40}}
if url.endswith('/backend/version'):
return {'product': 'FEDEO', 'version': self.reported_version}
if url.endswith('/health'):
return {'status': 'ok', 'product': 'FEDEO', 'version': '3.0.0' if self.bad_health else '3.1.0', 'revision': 'a' * 40}
raise AssertionError(url)
class Runner:
"""Real Compose parsing with a simulated daemon; no containers are started."""
def __init__(self, log, http):
self.log = log
self.http = http
self.calls = []
self.fail = None
self.on_pull = None
self.interrupt = None
self.matrix_containers = {}
self.on_stop = None
self.real_tar = False
def run(self, args, output=None, input_file=None, **kwargs):
self.calls.append(args)
if self.interrupt and self.interrupt(args):
raise KeyboardInterrupt()
if self.fail and self.fail(args):
raise u.UpdateError('simulated command failure')
if args[:3] == ['docker', 'compose', 'version']:
return '2.39.1'
if args[:2] == ['docker', 'info']:
return json.dumps({'OSType': 'linux', 'Architecture': 'amd64', 'DockerRootDir': '/tmp'})
if args[:3] == ['docker', 'context', 'inspect']:
return 'unix:///var/run/docker.sock'
if args[:3] == ['docker', 'image', 'inspect']:
return 'image-' + args[3].rsplit('/', 1)[-1].split('@')[0]
if args[:2] == ['docker', 'inspect']:
name = args[2]
if name in self.matrix_containers:
return json.dumps([self.matrix_containers[name]])
return json.dumps([{'Image': 'image-' + name, 'Config': {'Labels': {'com.docker.compose.service': name}},
'State': {'Running': name != 'createbuckets', 'Health': {'Status': 'healthy'}}}])
if args[:3] == ['docker', 'update', '--restart=no']:
self.matrix_containers[args[3]]['HostConfig']['RestartPolicy'] = {'Name': 'no', 'MaximumRetryCount': 0}
return args[3]
if 'config' in args:
result = subprocess.run(args, env={'PATH': os.environ['PATH'], 'HOME': os.environ['HOME']},
capture_output=True, text=True)
if result.returncode:
raise u.UpdateError('Compose invalid: ' + result.stderr)
return result.stdout
if 'ps' in args:
if args[:2] == ['docker', 'ps']:
return ''
if args[-1] in ('backend', 'frontend', 'db', 'minio') or args[-1] in self.matrix_containers:
return args[-1]
return 'backend\nfrontend\ndb\nminio\ntraefik\nnode-exporter\ncreatebuckets\n' + '\n'.join(self.matrix_containers)
if 'stop' in args or 'start' in args:
for name, container in self.matrix_containers.items():
if name in args:
container['State']['Running'] = 'start' in args
if 'stop' in args and self.on_stop:
self.on_stop()
if 'pull' in args and self.on_pull:
self.on_pull()
if any('pg_database_size' in part for part in args):
return '1000'
if args[0] == 'du':
return '1000\tminio'
if args[0] == 'tar' and self.real_tar:
with open(output, 'wb') if output else open(os.devnull, 'wb') as destination:
subprocess.run(args, stdout=destination, check=True)
return ''
if output:
Path(output).write_bytes(b'PGDMP fake dump')
if args[0] == 'tar' and '-cpf' in args:
Path(args[2]).write_bytes(b'backup')
return ''
class UpdateTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.root = Path(self.temp.name)
(self.root / 'minio').mkdir()
(self.root / 'postgres').mkdir()
(self.root / '.env').write_bytes(u.set_version((ROOT / '.env.example').read_bytes(), '3.0.0'))
(self.root / 'docker-compose.yml').write_bytes((ROOT / 'docker-compose.selfhost.yml').read_bytes())
self.original_env = (self.root / '.env').read_bytes()
self.original_compose = (self.root / 'docker-compose.yml').read_bytes()
self.http = Http()
self.runner = Runner(self.root / 'log', self.http)
self.updater = u.Updater(self.root, http=self.http, runner_factory=lambda log: self.runner)
self.updater.check_space = lambda *_: None
self.env_patch = patch.dict(os.environ, {'DOCKER_HOST': 'unix:///var/run/docker.sock'})
self.env_patch.start()
self.sleep_patch = patch.object(u.time, 'sleep')
self.sleep_patch.start()
def tearDown(self):
self.env_patch.stop()
self.sleep_patch.stop()
self.temp.cleanup()
def run_update(self, **kwargs):
return self.updater.execute('apply', '3.1.0', accept_compose=True, **kwargs)
def called(self, value):
return any(value in args for args in self.runner.calls)
def assert_untouched(self):
self.assertEqual((self.root / '.env').read_bytes(), self.original_env)
self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose)
self.assertFalse(self.called('stop'))
def add_legacy_matrix(self):
services = {}
for name in sorted(u.LEGACY_MATRIX_SERVICES):
services[name] = {'image': 'example.test/' + name + ':legacy', 'restart': 'unless-stopped',
'container_name': 'fedeo-' + name, 'networks': ['internal']}
self.runner.matrix_containers[name] = {
'Id': name, 'Image': 'image-' + name,
'Config': {'Labels': {'com.docker.compose.service': name}}, 'Mounts': [],
'HostConfig': {'RestartPolicy': {'Name': 'unless-stopped', 'MaximumRetryCount': 0}},
'State': {'Running': True, 'Health': {'Status': 'healthy'}}}
for name, directory, target in [('matrix-db', 'postgres', '/var/lib/postgresql/data'),
('matrix-synapse', 'synapse', '/data')]:
source = self.root / 'matrix' / directory
source.mkdir(parents=True)
(source / 'data').write_text('preserved ' + name)
services[name]['volumes'] = [{'type': 'bind', 'source': str(source), 'target': target}]
self.runner.matrix_containers[name]['Mounts'] = [{'Type': 'bind', 'Source': str(source), 'Destination': target}]
# Use the real Compose parser to model an older base file, including the removed dependency.
config = json.loads(subprocess.check_output(['docker', 'compose', '--project-directory', str(self.root),
'--env-file', str(self.root / '.env'), '-f', str(self.root / 'docker-compose.yml'),
'config', '--format', 'json'], env={'PATH': os.environ['PATH'], 'HOME': os.environ['HOME']}))
config['services'].update(services)
config['services']['backend']['depends_on']['matrix-synapse'] = {'condition': 'service_healthy'}
(self.root / 'docker-compose.yml').write_bytes(data(config))
self.original_compose = (self.root / 'docker-compose.yml').read_bytes()
def test_check_plans_matrix_retirement_without_stopping_or_disabling_it(self):
self.add_legacy_matrix()
result = self.updater.execute('check', '3.1.0')
self.assertEqual(set(result['plan']['retiredServices']), u.LEGACY_MATRIX_SERVICES)
self.assertEqual(result['plan']['retainedDataPaths'], ['matrix'])
self.assertEqual(result['plan']['estimatedBackupBytes'], 4000 + u.GIB)
self.assertFalse(self.called('--restart=no'))
self.assert_untouched()
def test_matrix_is_backed_up_then_retired_without_removing_containers_or_data(self):
self.add_legacy_matrix()
# An image-created Redis volume must remain attached to the retained container.
self.runner.matrix_containers['matrix-redis']['Mounts'] = [
{'Type': 'volume', 'Source': '/var/lib/docker/volumes/redis/_data', 'Destination': '/data', 'Name': 'redis'}]
result = self.run_update()
self.assertEqual(result['status'], 'succeeded')
self.assertNotIn(b'matrix-', (self.root / 'docker-compose.yml').read_bytes())
self.assertTrue((self.root / 'matrix/postgres/data').exists())
self.assertTrue((self.root / 'matrix/synapse/data').exists())
backup = Path(result['backup'])
self.assertEqual((backup / 'config/docker-compose.yml').read_bytes(), self.original_compose)
metadata = u.read_json(backup / 'complete.json')
self.assertEqual(metadata['matrixDataPaths'], ['matrix'])
self.assertEqual(set(metadata['retainedContainerIds']), u.LEGACY_MATRIX_SERVICES)
original = u.read_json(backup / 'containers.json')
self.assertEqual(next(c for c in original if c.get('Id') == 'matrix-db')['HostConfig']['RestartPolicy']['Name'], 'unless-stopped')
for container in self.runner.matrix_containers.values():
self.assertFalse(container['State']['Running'])
self.assertEqual(container['HostConfig']['RestartPolicy']['Name'], 'no')
calls = self.runner.calls
archive = next(i for i, a in enumerate(calls) if a[0] == 'tar' and '-cpf' in a)
retire = next(i for i, a in enumerate(calls) if '--restart=no' in a)
self.assertIn('matrix', calls[archive])
self.assertLess(archive, retire)
self.assertFalse(any('--remove-orphans' in a or a[:2] in (['docker', 'rm'], ['docker', 'volume']) for a in calls))
# Future updates see stopped Matrix orphans; they do not bring them back.
(self.root / '.env').write_bytes(self.original_env)
self.assertEqual(self.run_update()['status'], 'succeeded')
self.assertFalse(any(c['State']['Running'] for c in self.runner.matrix_containers.values()))
def test_matrix_archive_failure_preserves_base_and_restarts_original_stack(self):
self.add_legacy_matrix()
self.runner.fail = lambda a: a[0] == 'tar' and '-cpf' in a
with self.assertRaises(u.UpdateError):
self.run_update()
self.assertFalse(self.called('--restart=no'))
self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose)
self.assertFalse(self.updater.status()['recoveryRequired'])
self.assertTrue(all(c['State']['Running'] for c in self.runner.matrix_containers.values()))
def test_real_matrix_archive_contains_database_media_and_configuration(self):
self.add_legacy_matrix()
self.runner.real_tar = True
(self.root / 'matrix/livekit.yaml').write_text('preserved config')
(self.root / 'matrix/postgres/pg_wal').mkdir()
(self.root / 'matrix/postgres/pg_wal/segment').write_bytes(b'preserved WAL')
expected = {str(path.relative_to(self.root)): path.read_bytes()
for path in (self.root / 'matrix').rglob('*') if path.is_file()}
result = self.run_update()
backup = Path(result['backup'])
with tarfile.open(backup / 'files.tar') as archive:
for name, contents in expected.items():
self.assertEqual(archive.extractfile(name).read(), contents)
self.assertEqual(u.sha((backup / 'files.tar').read_bytes()), u.read_json(backup / 'complete.json')['sha256']['files.tar'])
def test_matrix_must_be_stopped_before_a_cold_backup(self):
self.add_legacy_matrix()
self.runner.on_stop = lambda: self.runner.matrix_containers['matrix-db']['State'].update(Running=True)
with self.assertRaisesRegex(u.UpdateError, 'Matrix-Container läuft noch'):
self.run_update()
self.assertFalse(self.called('-cpf'))
self.assertFalse(self.called('--restart=no'))
def test_interrupted_retirement_requires_recovery_and_preserves_backup(self):
self.add_legacy_matrix()
self.runner.interrupt = lambda a: '--restart=no' in a and a[-1] == 'matrix-element'
with self.assertRaises(KeyboardInterrupt):
self.run_update()
state = self.updater.status()
self.assertEqual(state['phase'], 'retiring_matrix')
self.assertTrue(state['recoveryRequired'])
self.assertTrue((Path(state['backup']) / 'complete.json').exists())
self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose)
self.assertFalse(any(c['State']['Running'] for c in self.runner.matrix_containers.values()))
def test_matrix_symlinks_and_nonstandard_mounts_are_rejected_before_stopping(self):
self.add_legacy_matrix()
link = self.root / 'matrix/postgres/pg_wal'
link.symlink_to(self.root / 'postgres', target_is_directory=True)
with self.assertRaisesRegex(u.UpdateError, 'Symbolische Links'):
self.run_update()
self.assert_untouched()
link.unlink()
self.runner.matrix_containers['matrix-db']['Mounts'][0]['Source'] = '/different/postgres'
with self.assertRaisesRegex(u.UpdateError, 'Tatsächliche Matrix-Datenpfade'):
self.run_update()
self.assert_untouched()
def test_unknown_additional_services_are_still_rejected(self):
self.add_legacy_matrix()
config = json.loads(self.original_compose)
config['services']['matrix-custom'] = {'image': 'example.test/custom:latest'}
(self.root / 'docker-compose.yml').write_bytes(data(config))
with self.assertRaisesRegex(u.UpdateError, 'matrix-custom'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_matrix_cannot_be_reintroduced_by_an_override(self):
self.add_legacy_matrix()
(self.root / 'docker-compose.override.yml').write_text('services:\n matrix-element:\n image: example.test/element:legacy\n')
with self.assertRaisesRegex(u.UpdateError, 'Zielkonfiguration.*matrix-element'):
self.run_update()
self.assert_untouched()
def test_nonstandard_matrix_bind_and_named_volume_are_rejected(self):
self.add_legacy_matrix()
for volume in [{'type': 'bind', 'source': str(self.root / 'postgres'), 'target': '/data'},
{'type': 'volume', 'source': 'matrixdata', 'target': '/data'}]:
with self.subTest(volume=volume['type']):
config = json.loads(self.original_compose)
config['services']['matrix-synapse']['volumes'] = [volume]
config['volumes'] = {'matrixdata': {}}
(self.root / 'docker-compose.yml').write_bytes(data(config))
with self.assertRaisesRegex(u.UpdateError, 'Matrix-Daten müssen'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_resolve_target_requires_matrix_to_remain_retired(self):
self.add_legacy_matrix()
self.runner.fail = lambda a: 'migrate' in a
with self.assertRaises(u.UpdateError):
self.run_update()
self.runner.fail = None
self.runner.matrix_containers['matrix-db']['HostConfig']['RestartPolicy']['Name'] = 'always'
with self.assertRaisesRegex(u.UpdateError, 'automatisch neu starten'):
self.updater.resolve('3.1.0')
self.assertTrue(self.updater.status()['recoveryRequired'])
self.runner.matrix_containers['matrix-db']['HostConfig']['RestartPolicy']['Name'] = 'no'
self.assertEqual(self.updater.resolve('3.1.0')['status'], 'recovered')
def test_resolve_rollback_requires_original_matrix_images_state_and_restart_policy(self):
self.add_legacy_matrix()
originals = copy.deepcopy(self.runner.matrix_containers)
self.runner.fail = lambda a: 'migrate' in a
with self.assertRaises(u.UpdateError):
self.run_update()
self.runner.fail = None
self.http.reported_version = '3.0.0'
(self.root / 'docker-compose.yml').write_bytes(self.original_compose)
(self.root / '.env').write_bytes(self.original_env)
with self.assertRaisesRegex(u.UpdateError, 'Matrix-Container und Neustartrichtlinien'):
self.updater.resolve('3.0.0')
self.assertTrue(self.updater.status()['recoveryRequired'])
self.runner.matrix_containers = copy.deepcopy(originals)
for key, value in [('Image', 'wrong-image'), ('HostConfig', {'RestartPolicy': {'Name': 'no'}}),
('State', {'Running': True, 'Health': {'Status': 'unhealthy'}})]:
with self.subTest(key=key):
self.runner.matrix_containers['matrix-db'][key] = value
with self.assertRaisesRegex(u.UpdateError, 'Matrix-Container und Neustartrichtlinien'):
self.updater.resolve('3.0.0')
self.runner.matrix_containers = copy.deepcopy(originals)
self.assertEqual(self.updater.resolve('3.0.0')['status'], 'recovered')
self.assertFalse(self.updater.status()['recoveryRequired'])
def test_success_pins_images_preserves_configuration_and_checks_both_public_services(self):
result = self.run_update()
self.assertEqual(result['status'], 'succeeded')
self.assertFalse(result['recoveryRequired'])
self.assertEqual((self.root / '.env').read_bytes(), u.set_version(self.original_env, '3.1.0'))
self.assertIn(b'backend@sha256:' + b'a' * 64, (self.root / 'docker-compose.yml').read_bytes())
backup = Path(result['backup'])
self.assertTrue((backup / 'complete.json').exists())
self.assertEqual((backup / 'config/.env').read_bytes(), self.original_env)
self.assertTrue(any(url.endswith('/backend/health') for url in self.http.requests))
self.assertTrue(any(url.endswith('/api/health') for url in self.http.requests))
self.assertEqual((self.root / '.env').stat().st_mode & 0o777, 0o600)
self.assertEqual((self.root / '.updates').stat().st_mode & 0o777, 0o700)
self.assertFalse(self.called('down'))
self.assertFalse(self.called('--remove-orphans'))
calls = self.runner.calls
self.assertLess(next(i for i, a in enumerate(calls) if 'pull' in a), next(i for i, a in enumerate(calls) if 'stop' in a))
self.assertLess(next(i for i, a in enumerate(calls) if 'pg_restore' in a), next(i for i, a in enumerate(calls) if 'migrate' in a))
def test_check_only_creates_a_plan_and_ignores_unpublished_releases(self):
result = self.updater.execute('check')
self.assertEqual(result['version'], '3.1.0')
self.assertEqual(result['status'], 'checked')
self.assertTrue(Path(result['plan']['composeDiff']).exists())
self.assert_untouched()
self.assertFalse(self.called('pull'))
def test_checksum_failure_does_not_stop_anything(self):
self.http.files['docker-compose.selfhost.yml'] += b'\n# tampered'
with self.assertRaisesRegex(u.UpdateError, 'Prüfsumme'):
self.run_update()
self.assert_untouched()
def test_download_failure_does_not_stop_anything(self):
self.http.fail_download = True
with self.assertRaisesRegex(u.UpdateError, 'Download'):
self.run_update()
self.assert_untouched()
def test_draft_is_rejected_even_when_explicitly_selected(self):
self.http.release['draft'] = True
with self.assertRaisesRegex(u.UpdateError, 'veröffentlichte'):
self.run_update()
self.assert_untouched()
def test_manifest_commit_must_match_git_tag(self):
self.http.manifest['commit'] = 'b' * 40
self.http.files['release-manifest.json'] = data(self.http.manifest)
self.http.refresh()
with self.assertRaisesRegex(u.UpdateError, 'Git-Tag'):
self.run_update()
self.assert_untouched()
def test_invalid_compose_fails_before_stopping(self):
self.http.files['docker-compose.selfhost.yml'] += b'\nbroken: [\n'
self.http.refresh()
with self.assertRaises(u.UpdateError):
self.run_update()
self.assert_untouched()
def test_unknown_base_requires_review(self):
with self.assertRaisesRegex(u.UpdateError, 'compose.diff'):
self.updater.execute('apply', '3.1.0')
self.assert_untouched()
def test_override_is_preserved_but_cannot_replace_release_images(self):
override = self.root / 'docker-compose.override.yml'
contents = 'services:\n frontend:\n environment:\n MY_CUSTOM_SETTING: preserved\n'
override.write_text(contents)
self.run_update()
self.assertEqual(override.read_text(), contents)
self.assertEqual(json.loads((self.root / '.updates/installed.json').read_text())['manifest']['version'], '3.1.0')
def test_override_cannot_disable_healthcheck(self):
(self.root / 'docker-compose.override.yml').write_text('services:\n backend:\n healthcheck:\n disable: true\n')
with self.assertRaisesRegex(u.UpdateError, 'Healthcheck'):
self.run_update()
self.assert_untouched()
def test_changed_mount_is_rejected(self):
self.http.files['docker-compose.selfhost.yml'] = self.http.files['docker-compose.selfhost.yml'].replace(b'./postgres:', b'./different:')
self.http.refresh()
with self.assertRaisesRegex(u.UpdateError, 'Datenpfade'):
self.run_update()
self.assert_untouched()
def test_first_update_can_add_only_the_readonly_control_directory(self):
control_line = b' - /run/fedeo-updater:/run/fedeo-updater:ro\n'
self.assertIn(control_line, self.original_compose)
(self.root / 'docker-compose.yml').write_bytes(self.original_compose.replace(control_line, b' []\n'))
result = self.run_update()
self.assertEqual(result['status'], 'succeeded')
self.assertIn(control_line, (self.root / 'docker-compose.yml').read_bytes())
def test_control_mount_bootstrap_rejects_write_access_or_another_host_directory(self):
control_line = b' - /run/fedeo-updater:/run/fedeo-updater:ro\n'
(self.root / 'docker-compose.yml').write_bytes(self.original_compose.replace(control_line, b' []\n'))
for replacement in (b' - /run/fedeo-updater:/run/fedeo-updater:rw\n',
b' - /var/run/docker.sock:/run/fedeo-updater:ro\n'):
with self.subTest(replacement=replacement):
self.http.files['docker-compose.selfhost.yml'] = self.original_compose.replace(control_line, replacement)
self.http.refresh()
with self.assertRaisesRegex(u.UpdateError, 'Datenpfade'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_external_storage_fails_before_maintenance(self):
(self.root / '.env').write_bytes(self.original_env.replace(b'http://minio:9000', b'https://storage.example.test'))
with self.assertRaisesRegex(u.UpdateError, 'Backup-Adapter'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_database_url_must_match_the_database_being_backed_up(self):
(self.root / '.env').write_bytes(self.original_env.replace(b'@db:5432/fedeo', b'@db:5432/other_database'))
with self.assertRaisesRegex(u.UpdateError, 'gesicherte lokale'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_missing_required_secret_is_rejected(self):
(self.root / '.env').write_bytes(self.original_env.replace(b'ENCRYPTION_KEY=', b'IGNORED_KEY='))
with self.assertRaisesRegex(u.UpdateError, 'ENCRYPTION_KEY'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_pull_failure_never_stops_old_stack(self):
self.runner.fail = lambda args: 'pull' in args
with self.assertRaises(u.UpdateError):
self.run_update()
self.assert_untouched()
def test_configuration_changed_during_pull_is_detected(self):
self.runner.on_pull = lambda: (self.root / '.env').write_bytes(self.original_env + b'\nNEW=value\n')
with self.assertRaisesRegex(u.UpdateError, 'während'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_backup_failure_restarts_previous_services_and_preserves_configuration(self):
self.runner.fail = lambda args: any('pg_dump ' in arg for arg in args)
with self.assertRaises(u.UpdateError):
self.run_update()
self.assertTrue(self.called('start'))
self.assertFalse(self.called('migrate'))
self.assertFalse(self.updater.status()['recoveryRequired'])
self.assertEqual((self.root / '.env').read_bytes(), self.original_env)
self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose)
def test_migration_failure_keeps_recovery_state_and_never_starts_old_application(self):
self.runner.fail = lambda args: 'migrate' in args
with self.assertRaises(u.UpdateError):
self.run_update()
state = self.updater.status()
self.assertEqual(state['phase'], 'migrating')
self.assertTrue(state['recoveryRequired'])
self.assertTrue(Path(state['backup']).exists())
self.assertFalse(self.called('start'))
with self.assertRaisesRegex(u.UpdateError, 'Wiederherstellung'):
self.run_update()
def test_interrupt_during_migration_is_persisted(self):
self.runner.interrupt = lambda args: 'migrate' in args
with self.assertRaises(KeyboardInterrupt):
self.run_update()
self.assertEqual(self.updater.status()['status'], 'interrupted')
self.assertTrue(self.updater.status()['recoveryRequired'])
self.assertFalse(self.called('start'))
def test_process_death_is_detected_and_does_not_erase_recovery_state(self):
job = self.root / '.updates/interrupted'
job.mkdir()
u.write_json(self.root / '.updates/status.json', {'status': 'running', 'job': str(job), 'recoveryRequired': True})
self.assertEqual(self.updater.status()['status'], 'interrupted')
self.assertTrue(self.updater.status()['recoveryRequired'])
with self.assertRaisesRegex(u.UpdateError, 'Wiederherstellung'):
self.run_update()
def test_lock_prevents_parallel_updates(self):
with self.updater.lock():
other = u.Updater(self.root, http=self.http)
with self.assertRaisesRegex(u.UpdateError, 'läuft bereits'):
other.execute('check')
def test_old_migrations_must_not_change(self):
u.write_json(self.root / '.updates/installed.json', {'manifest': {'migrations': [{'path': 'old.sql', 'sha256': 'a' * 64}]}})
with self.assertRaisesRegex(u.UpdateError, 'Migrationen'):
self.run_update()
self.assert_untouched()
def test_insufficient_space_does_not_stop_services(self):
self.updater.check_space = lambda *_: (_ for _ in ()).throw(u.UpdateError('Zu wenig freier Speicher'))
with self.assertRaisesRegex(u.UpdateError, 'Speicher'):
self.run_update()
self.assert_untouched()
def test_failed_health_verification_does_not_mark_release_installed(self):
self.http.bad_health = True
with self.assertRaisesRegex(u.UpdateError, 'Funktionsprüfung'):
self.run_update()
self.assertEqual(self.updater.status()['phase'], 'verifying')
self.assertTrue(self.updater.status()['recoveryRequired'])
self.assertFalse((self.root / '.updates/installed.json').exists())
self.assertEqual(self.runner.calls[-1][-3:], ['backend', 'frontend', 'traefik'])
def test_resolve_requires_a_working_manually_recovered_installation(self):
self.runner.fail = lambda args: 'migrate' in args
with self.assertRaises(u.UpdateError):
self.run_update()
self.runner.fail = None
with self.assertRaisesRegex(u.UpdateError, 'Version stimmt nicht'):
self.updater.resolve('3.0.0')
self.assertTrue(self.updater.status()['recoveryRequired'])
self.assertEqual(self.updater.resolve('3.1.0')['status'], 'recovered')
self.assertFalse(self.updater.status()['recoveryRequired'])
self.assertEqual(u.read_json(self.root / '.updates/installed.json')['manifest']['version'], '3.1.0')
def test_second_managed_update_does_not_need_adoption(self):
u.write_json(self.root / '.updates/installed.json', {'composeSha256': u.sha(self.original_compose)})
self.assertEqual(self.updater.execute('apply', '3.1.0')['status'], 'succeeded')
def test_downgrade_is_rejected(self):
(self.root / '.env').write_bytes(u.set_version(self.original_env, '3.2.0'))
with self.assertRaisesRegex(u.UpdateError, 'Downgrades'):
self.run_update()
self.assertFalse(self.called('stop'))
def test_check_current_release_reports_no_available_update(self):
(self.root / '.env').write_bytes(u.set_version(self.original_env, '3.1.0'))
result = self.updater.execute('check', '3.1.0')
self.assertEqual(result['status'], 'checked')
self.assertFalse(result['plan']['updateAvailable'])
self.assertFalse(self.called('pull'))
with self.assertRaisesRegex(u.UpdateError, 'erneute Installation'):
self.run_update()
def test_reviewed_plan_can_be_applied_but_configuration_changes_require_recheck(self):
result = self.updater.execute('check', '3.1.0', request_id='check-request')
self.assertEqual(self.updater.status()['requestId'], 'check-request')
fingerprint = result['plan']['approvalFingerprint']
(self.root / '.env').write_bytes(self.original_env + b'\nCUSTOM_SETTING=changed\n')
with self.assertRaises(u.PlanChangedError):
self.run_update(expected_fingerprint=fingerprint)
self.assertFalse(self.called('pull'))
self.assertFalse(self.called('stop'))
result = self.updater.execute('check', '3.1.0')
self.assertEqual(self.run_update(expected_fingerprint=result['plan']['approvalFingerprint'])['status'], 'succeeded')
def test_changed_release_digests_cannot_reuse_an_approved_plan(self):
result = self.updater.execute('check', '3.1.0')
self.http.manifest['images'][0]['digest'] = 'sha256:' + 'c' * 64
self.http.files['release-manifest.json'] = data(self.http.manifest)
self.http.refresh()
with self.assertRaises(u.PlanChangedError):
self.run_update(expected_fingerprint=result['plan']['approvalFingerprint'])
self.assert_untouched()
self.assertFalse(self.called('pull'))
def test_backup_integrity_manifest_is_complete(self):
result = self.run_update()
backup = Path(result['backup'])
manifest = u.read_json(backup / 'complete.json')
for name in ('database.dump', 'globals.sql', 'files.tar', 'config/.env', 'config/docker-compose.yml'):
self.assertEqual(u.sha((backup / name).read_bytes()), manifest['sha256'][name])
class HttpsTests(unittest.TestCase):
def test_token_is_removed_after_cross_origin_redirect(self):
requests = []
class Opener:
def open(self, request, timeout):
requests.append(request)
if len(requests) == 1:
raise u.urllib.error.HTTPError(request.full_url, 302, 'redirect',
{'Location': 'https://objects.example.test/file'}, None)
return io.BytesIO(b'asset')
with patch.object(u.urllib.request, 'build_opener', return_value=Opener()):
self.assertEqual(u.Https('private-token').get(u.REPOSITORY + '/asset'), b'asset')
self.assertEqual(requests[0].get_header('Authorization'), 'token private-token')
self.assertIsNone(requests[1].get_header('Authorization'))
def test_insecure_redirect_is_rejected(self):
class Opener:
def open(self, request, timeout):
raise u.urllib.error.HTTPError(request.full_url, 302, 'redirect',
{'Location': 'http://objects.example.test/file'}, None)
with patch.object(u.urllib.request, 'build_opener', return_value=Opener()):
with self.assertRaisesRegex(u.UpdateError, 'HTTPS'):
u.Https().get(u.REPOSITORY + '/asset')
def test_download_size_is_limited(self):
class Opener:
def open(self, request, timeout):
return io.BytesIO(b'123456')
with patch.object(u.urllib.request, 'build_opener', return_value=Opener()):
with self.assertRaisesRegex(u.UpdateError, 'Größe'):
u.Https().get(u.REPOSITORY + '/asset', limit=5)
if __name__ == '__main__':
unittest.main()