Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 20091143d2 | |||
| 8e1505023b | |||
| 60553bbba8 | |||
| 96295ee4f0 | |||
| f708d0a2b2 | |||
| 7164989da9 | |||
| cbec5b058d | |||
| 847a616285 | |||
| fdf1d42a3d | |||
| 7e9f4409f6 | |||
| 0ff74cdcdf | |||
| f51f2b13a1 |
@@ -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
1
.gitignore
vendored
@@ -3,6 +3,7 @@ node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
/.release/
|
||||
__pycache__/
|
||||
|
||||
# Lokale Runtime-Daten und generierte Konfigurationen
|
||||
matrix/postgres/
|
||||
|
||||
40
README.md
40
README.md
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "statementallocations"
|
||||
ADD COLUMN "costcentre" uuid REFERENCES "costcentres"("id");
|
||||
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal file
51
backend/db/migrations/0072_restore_instance_scan_agents.sql
Normal file
@@ -0,0 +1,51 @@
|
||||
-- Forward repair: 0051-0053 were shipped without migration journal entries.
|
||||
-- Keep existing agents/jobs when these SQL files were applied manually.
|
||||
CREATE TABLE IF NOT EXISTS "instance_agents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"token_prefix" text NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"active" boolean DEFAULT true NOT NULL,
|
||||
"capabilities" jsonb DEFAULT '{"scan":true,"print":false}'::jsonb NOT NULL,
|
||||
"scanner_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"printer_names" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"last_seen_at" timestamp with time zone,
|
||||
"last_debug_info" jsonb,
|
||||
CONSTRAINT "instance_agents_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "instance_agent_scan_jobs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"tenant_id" bigint NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"requested_by" uuid,
|
||||
"status" text DEFAULT 'pending' NOT NULL,
|
||||
"scanner_name" text,
|
||||
"requested_filename" text,
|
||||
"settings" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"target" jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
"agent_message" text,
|
||||
"attempts" integer DEFAULT 0 NOT NULL,
|
||||
"claimed_at" timestamp with time zone,
|
||||
"finished_at" timestamp with time zone,
|
||||
"file_id" uuid,
|
||||
CONSTRAINT "instance_agent_scan_jobs_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade,
|
||||
CONSTRAINT "instance_agent_scan_jobs_agent_id_instance_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."instance_agents"("id") ON DELETE cascade ON UPDATE cascade,
|
||||
CONSTRAINT "instance_agent_scan_jobs_requested_by_auth_users_id_fk" FOREIGN KEY ("requested_by") REFERENCES "public"."auth_users"("id") ON DELETE set null ON UPDATE cascade,
|
||||
CONSTRAINT "instance_agent_scan_jobs_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_agent_status_idx" ON "instance_agent_scan_jobs" USING btree ("agent_id","status","created_at");
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "instance_agent_scan_jobs_tenant_idx" ON "instance_agent_scan_jobs" USING btree ("tenant_id","created_at");
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "preferred_scanner_name" text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_agents" ADD COLUMN IF NOT EXISTS "scan_defaults" jsonb DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null}'::jsonb NOT NULL;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "instance_agents" ALTER COLUMN "scan_defaults" SET DEFAULT '{"format":"pdf","resolution":300,"mode":"Color","source":null,"postprocess":false,"postprocessProfile":"document"}'::jsonb;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" },
|
||||
|
||||
30
backend/src/modules/bank-allocation-details.ts
Normal file
30
backend/src/modules/bank-allocation-details.ts
Normal 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.")
|
||||
}
|
||||
91
backend/src/modules/selfhost-updater.ts
Normal file
91
backend/src/modules/selfhost-updater.ts
Normal 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);
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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" })
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
38
backend/src/routes/selfhostUpdates.ts
Normal file
38
backend/src/routes/selfhostUpdates.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
18
backend/src/utils/requireSystemAdmin.ts
Normal file
18
backend/src/utils/requireSystemAdmin.ts
Normal 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;
|
||||
}
|
||||
69
backend/tests/bankAllocationDetails.test.ts
Normal file
69
backend/tests/bankAllocationDetails.test.ts
Normal 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)
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
133
backend/tests/instanceAgentMigration.test.ts
Normal file
133
backend/tests/instanceAgentMigration.test.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { execFile } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import { readFile, mkdir, mkdtemp, writeFile, rm } from "node:fs/promises"
|
||||
import { readFileSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import os from "node:os"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { Pool } from "pg"
|
||||
import { drizzle } from "drizzle-orm/node-postgres"
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator"
|
||||
import { readMigrationFiles } from "drizzle-orm/migrator"
|
||||
import Fastify from "fastify"
|
||||
import routes from "../src/routes/instanceAgents"
|
||||
|
||||
const migrationsFolder = path.resolve(__dirname, "../db/migrations") + path.sep
|
||||
const repairTag = "0072_restore_instance_scan_agents"
|
||||
const journal = JSON.parse(readFileSync(migrationsFolder + "meta/_journal.json", "utf8")).entries
|
||||
const repair = journal.find((entry: any) => entry.tag === repairTag)
|
||||
const previousWatermark = Math.max(...journal.filter((entry: any) => entry.idx < repair?.idx).map((entry: any) => entry.when))
|
||||
const execute = promisify(execFile)
|
||||
|
||||
test("the scanner repair is registered after every previously shipped migration", () => {
|
||||
assert.ok(repair, "scanner repair must be in the Drizzle journal")
|
||||
assert.ok(repair.when > previousWatermark)
|
||||
const pending = readMigrationFiles({ migrationsFolder }).filter(entry => entry.folderMillis > previousWatermark)
|
||||
assert.ok(pending.some(entry => entry.sql.some(sql => sql.includes('CREATE TABLE IF NOT EXISTS "instance_agents"'))))
|
||||
})
|
||||
|
||||
// Opt in with FEDEO_TEST_POSTGRES_IMAGE=postgres:16-alpine. This creates its own
|
||||
// disposable database container; it never reads DATABASE_URL or application secrets.
|
||||
test("migration repairs upgraded databases and preserves manually installed scanners", {
|
||||
skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE,
|
||||
}, async t => {
|
||||
// Isolate this repair from unrelated future migrations. Registration in the
|
||||
// actual shipped journal is checked above; its timestamp remains unchanged.
|
||||
const testMigrations = await mkdtemp(path.join(os.tmpdir(), "fedeo-scan-migrations-"))
|
||||
t.after(() => rm(testMigrations, { recursive: true, force: true }))
|
||||
await mkdir(path.join(testMigrations, "meta"))
|
||||
await writeFile(path.join(testMigrations, "meta/_journal.json"), JSON.stringify({ version: "7", dialect: "postgresql", entries: [repair] }))
|
||||
await writeFile(path.join(testMigrations, repairTag + ".sql"), await readFile(migrationsFolder + repairTag + ".sql"))
|
||||
const container = "fedeo-scan-migration-" + randomUUID()
|
||||
const password = randomUUID()
|
||||
await execute("docker", ["run", "--rm", "-d", "--name", container,
|
||||
"-e", "POSTGRES_PASSWORD=" + password, "-p", "127.0.0.1::5432",
|
||||
process.env.FEDEO_TEST_POSTGRES_IMAGE!])
|
||||
let admin: Pool | undefined
|
||||
t.after(async () => {
|
||||
try { await admin?.end() }
|
||||
finally { await execute("docker", ["rm", "-f", container]) }
|
||||
})
|
||||
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
|
||||
const port = Number(stdout.trim().split(":").at(-1))
|
||||
admin = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 })
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try { await admin.query("SELECT 1"); break } catch (error) {
|
||||
if (attempt >= 80) throw error
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
for (const state of ["absent", "0051", "0052", "0053"]) {
|
||||
await t.test(state, async () => {
|
||||
const database = "scan_" + state
|
||||
await admin.query('CREATE DATABASE "' + database + '"')
|
||||
const pool = new Pool({ host: "127.0.0.1", port, user: "postgres", password, database, max: 1 })
|
||||
const app = Fastify()
|
||||
try {
|
||||
await pool.query(`
|
||||
CREATE TABLE tenants (id bigint PRIMARY KEY);
|
||||
CREATE TABLE auth_users (id uuid PRIMARY KEY);
|
||||
CREATE TABLE files (id uuid PRIMARY KEY);
|
||||
INSERT INTO tenants VALUES (1);
|
||||
CREATE SCHEMA drizzle;
|
||||
CREATE TABLE drizzle.__drizzle_migrations (id serial PRIMARY KEY, hash text NOT NULL, created_at bigint);
|
||||
`)
|
||||
// An existing installation already passed 0071. Re-inserting old
|
||||
// journal entries 0051-0053 would therefore NOT fix this database.
|
||||
await pool.query("INSERT INTO drizzle.__drizzle_migrations (hash, created_at) VALUES ('previous-release', $1)", [previousWatermark])
|
||||
if (state !== "absent") {
|
||||
for (const file of ["0051_instance_scan_agents", "0052_instance_agent_scan_defaults", "0053_instance_agent_postprocess_defaults"]) {
|
||||
if (file.slice(0, 4) <= state) await pool.query(await readFile(migrationsFolder + file + ".sql", "utf8"))
|
||||
}
|
||||
await pool.query(`
|
||||
INSERT INTO instance_agents (id, name, token_prefix, token_hash)
|
||||
VALUES ('00000000-0000-4000-8000-000000000001', 'Existing scanner', 'prefix', 'existing-hash');
|
||||
INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id, status, settings)
|
||||
VALUES (1, '00000000-0000-4000-8000-000000000001', 'completed', '{"resolution":600}');
|
||||
`)
|
||||
if (state !== "0051") {
|
||||
await pool.query(`UPDATE instance_agents SET preferred_scanner_name='existing-device', scan_defaults='{"resolution":600}'`)
|
||||
}
|
||||
}
|
||||
|
||||
const db = drizzle(pool)
|
||||
await migrate(db, { migrationsFolder: testMigrations })
|
||||
await migrate(db, { migrationsFolder: testMigrations })
|
||||
// Also prove the repair SQL itself is safe after manual application.
|
||||
await pool.query(await readFile(migrationsFolder + repairTag + ".sql", "utf8"))
|
||||
app.decorate("db", db)
|
||||
app.addHook("onRequest", async req => { req.user = { is_admin: true } as any })
|
||||
await app.register(routes)
|
||||
const listing = await app.inject({ method: "GET", url: "/instance-agents" })
|
||||
assert.equal(listing.statusCode, 200, listing.body)
|
||||
const agents = listing.json().agents
|
||||
assert.equal(agents.length, state === "absent" ? 0 : 1)
|
||||
if (state !== "absent") {
|
||||
assert.equal(agents[0].name, "Existing scanner")
|
||||
assert.equal((await pool.query("SELECT token_hash FROM instance_agents")).rows[0].token_hash, "existing-hash")
|
||||
assert.equal((await pool.query("SELECT status, settings FROM instance_agent_scan_jobs")).rows[0].settings.resolution, 600)
|
||||
if (state !== "0051") {
|
||||
assert.equal(agents[0].preferredScannerName, "existing-device")
|
||||
assert.deepEqual(agents[0].scanDefaults, { resolution: 600 })
|
||||
}
|
||||
}
|
||||
const created = await app.inject({ method: "POST", url: "/instance-agents", payload: { name: "New scanner" } })
|
||||
assert.equal(created.statusCode, 200, created.body)
|
||||
assert.equal(created.json().agent.scanDefaults.postprocess, false)
|
||||
assert.equal(created.json().agent.scanDefaults.postprocessProfile, "document")
|
||||
assert.equal(created.json().agent.scanDefaults.format, "pdf")
|
||||
assert.equal((await pool.query("SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations WHERE created_at = $1", [repair.when])).rows[0].count, 1)
|
||||
const indexes = (await pool.query("SELECT indexname FROM pg_indexes WHERE tablename='instance_agent_scan_jobs'")).rows.map(row => row.indexname)
|
||||
assert.ok(indexes.includes("instance_agent_scan_jobs_agent_status_idx"))
|
||||
assert.ok(indexes.includes("instance_agent_scan_jobs_tenant_idx"))
|
||||
await assert.rejects(pool.query("INSERT INTO instance_agent_scan_jobs (tenant_id, agent_id) VALUES (999, $1)", [created.json().agent.id]), { code: "23503" })
|
||||
} finally {
|
||||
await app.close()
|
||||
await pool.end()
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
119
backend/tests/selfhostUpdates.test.ts
Normal file
119
backend/tests/selfhostUpdates.test.ts
Normal 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"));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
198
docs/selfhost-updates.md
Normal 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.
|
||||
595
frontend/components/DashboardHome.vue
Normal file
595
frontend/components/DashboardHome.vue
Normal 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>
|
||||
87
frontend/components/SelfhostUpdateCard.vue
Normal file
87
frontend/components/SelfhostUpdateCard.vue
Normal 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>
|
||||
@@ -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 }">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
149
frontend/composables/useSelfhostUpdates.ts
Normal file
149
frontend/composables/useSelfhostUpdates.ts
Normal 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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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'"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</UModal>
|
||||
</UDashboardPanelContent>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
10
frontend/server/api/health.get.ts
Normal file
10
frontend/server/api/health.get.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
63
frontend/tests/bank-allocation-tax.test.cjs
Normal file
63
frontend/tests/bank-allocation-tax.test.cjs
Normal 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)
|
||||
})
|
||||
132
frontend/tests/dashboard-session.test.cjs
Normal file
132
frontend/tests/dashboard-session.test.cjs
Normal 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))
|
||||
})
|
||||
101
frontend/tests/selfhost-updates.test.cjs
Normal file
101
frontend/tests/selfhost-updates.test.cjs
Normal 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)
|
||||
})
|
||||
43
frontend/utils/bankAllocationTax.ts
Normal file
43
frontend/utils/bankAllocationTax.ts
Normal 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 }
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
33
scripts/fedeo-updater.service
Normal file
33
scripts/fedeo-updater.service
Normal 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
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
93
scripts/selfhost-update-service-install.sh
Normal file
93
scripts/selfhost-update-service-install.sh
Normal 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"
|
||||
376
scripts/selfhost-update-service.py
Normal file
376
scripts/selfhost-update-service.py
Normal 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
746
scripts/selfhost-update.py
Normal 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())
|
||||
269
scripts/update/tests/test_service.py
Normal file
269
scripts/update/tests/test_service.py
Normal 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()
|
||||
667
scripts/update/tests/test_updater.py
Normal file
667
scripts/update/tests/test_updater.py
Normal 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()
|
||||
Reference in New Issue
Block a user