dev #227

Merged
flfeders merged 4 commits from dev into main 2026-09-23 16:15:45 +00:00
33 changed files with 1737 additions and 60 deletions

View File

@@ -499,6 +499,8 @@ sudo python3 /opt/fedeo/scripts/selfhost-update.py status
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. 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.
Optional führt `fedeo-updater.service` die Aufträge unabhängig von der anfragenden Verbindung aus. Nach Bereitstellung der zugehörigen Dienstdateien installiert `sudo bash /opt/fedeo/scripts/selfhost-update-service-install.sh` den systemd-Dienst. Die lokale Unix-Socket-API bietet Prüfung, Update-Start und Status; Backend-Anbindung und Frontend-Button folgen separat. Einrichtung und API-Beispiele stehen in der folgenden Anleitung.
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). 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 ## Backup-Empfehlung

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, or } from "drizzle-orm" import { and, desc, eq, ilike, or } from "drizzle-orm"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
import { import {
accounts, accounts,
bankstatements, bankstatements,
@@ -406,6 +407,7 @@ const assertTenantEntityExists = async (
const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => { const assertStatementAllocationReferences = async (context: any, payload: Record<string, any>) => {
await Promise.all([ await Promise.all([
validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre),
assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"), assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"),
assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"), assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"),
assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"), 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." }, 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." }, manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." },
description: { type: "string" }, 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" }, bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" },
depreciationMonths: { type: "number", minimum: 1 }, depreciationMonths: { type: "number", minimum: 1 },
depreciationStartDate: { type: "string", format: "date" }, depreciationStartDate: { type: "string", format: "date" },

View File

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

View File

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

View File

@@ -23,7 +23,8 @@ import {
vendors, vendors,
bankaccounts, bankaccounts,
bankstatements, bankstatements,
ownaccounts ownaccounts,
costcentres
} from "../../../db/schema"; } from "../../../db/schema";
dayjs.extend(isBetween); dayjs.extend(isBetween);
@@ -80,6 +81,29 @@ const formatDatevDate = (date: dayjs.ConfigType, format: string) => {
return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : ""; 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) => { const getIncomingInvoiceTaxOverride = (invoice: any) => {
if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null; if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null;
@@ -136,7 +160,7 @@ export async function buildExportZip(
const endDateFmt = formatDatevDate(endDate, "YYYYMMDD"); 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 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) // 1. DATEN LADEN (CORE API SELECT & JOIN)
@@ -209,6 +233,7 @@ export async function buildExportZip(
direct_vend: vendors, // Direkte Zuordnung an Kreditor direct_vend: vendors, // Direkte Zuordnung an Kreditor
direct_cust: customers, // Direkte Zuordnung an Debitor direct_cust: customers, // Direkte Zuordnung an Debitor
own: ownaccounts, own: ownaccounts,
costcentre: costcentres,
contra_acc: ContraAccount, contra_acc: ContraAccount,
contra_vend: ContraVendor, contra_vend: ContraVendor,
contra_cust: ContraCustomer, contra_cust: ContraCustomer,
@@ -233,6 +258,7 @@ export async function buildExportZip(
.leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) .leftJoin(vendors, eq(statementallocations.vendor, vendors.id))
.leftJoin(customers, eq(statementallocations.customer, customers.id)) .leftJoin(customers, eq(statementallocations.customer, customers.id))
.leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.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(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id))
.leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id)) .leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id))
.leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.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 // Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet
const statementallocationsList = allocRaw.map(r => ({ const statementallocationsList = allocRaw.map(r => ({
...r.allocation, ...r.allocation,
bankstatement: { bankstatement: r.bs ? {
...r.bs, ...r.bs,
account: r.ba // Nesting für bs.account.datevNumber account: r.ba // Nesting für bs.account.datevNumber
}, } : null,
createddocument: r.cd ? { createddocument: r.cd ? {
...r.cd, ...r.cd,
customer: r.cd_cust customer: r.cd_cust
@@ -273,6 +299,7 @@ export async function buildExportZip(
vendor: r.direct_vend, vendor: r.direct_vend,
customer: r.direct_cust, customer: r.direct_cust,
ownaccount: r.own, ownaccount: r.own,
costcentre: r.costcentre,
contraAccount: r.contra_acc, contraAccount: r.contra_acc,
contraVendor: r.contra_vend, contraVendor: r.contra_vend,
contraCustomer: r.contra_cust, contraCustomer: r.contra_cust,
@@ -453,9 +480,7 @@ export async function buildExportZip(
const vend = ii.vendor; 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;;;;"";;;;;;`); 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) { } else if(alloc.account) {
const acc = alloc.account; bookingLines.push(getBankAccountBookingLine(alloc));
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;;;;"";;;;;;`);
} else if(alloc.vendor) { } else if(alloc.vendor) {
const vend = alloc.vendor; const vend = alloc.vendor;
let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; 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"; 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;;;;"";;;;;;`); 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) { } else if(alloc.ownaccount) {
const own = alloc.ownaccount; bookingLines.push(getBankAccountBookingLine(alloc));
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;;;;"";;;;;;`);
} }
}); });

View File

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

View File

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

View File

@@ -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. - `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist. - `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 ## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden. - Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

View File

@@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn
- `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien. - `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien.
- `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist. - `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 ## Gute Praxis
- Regelmäßig abrufen, damit sich keine großen Rückstände bilden. - Regelmäßig abrufen, damit sich keine großen Rückstände bilden.

View File

@@ -53,7 +53,7 @@ Backend, Frontend, Dokumentation, Website und zentrale Dienste werden als gemein
- Bewegliche Kanäle: `latest` und `main` zeigen nach erfolgreicher Veröffentlichung auf denselben Release. - 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. - 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-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`, `selfhost-update.py`, `SELFHOST-UPDATES.md` 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. 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.

View File

@@ -1,6 +1,6 @@
# Vollständige Selfhost-Updates # Vollständige Selfhost-Updates
`scripts/selfhost-update.py` ist der gemeinsame Update-Ablauf für die Kommandozeile und einen zukünftigen Update-Dienst. Diese Implementierung installiert noch keinen Dienst und stellt noch keinen Update-Button bereit. Der Prozess muss auf dem Docker-Host außerhalb der FEDEO-Container laufen. `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. Der Dienst bietet bereits eine lokale Schnittstelle; Backend-Anbindung und Update-Button im Frontend folgen separat.
## Voraussetzungen und Umfang ## Voraussetzungen und Umfang
@@ -13,12 +13,20 @@
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. 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. 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 ## 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. 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. 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 ## Prüfen, anwenden und Status lesen
@@ -32,7 +40,7 @@ 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. `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. Unveröffentlichte Entwürfe und Vorabversionen werden ignoriert. 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: 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:
@@ -47,9 +55,72 @@ sudo python3 /opt/fedeo/scripts/selfhost-update.py apply --version 3.2.0
sudo python3 /opt/fedeo/scripts/selfhost-update.py status 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. Bei gleichen oder älteren stabilen Zielversionen wird abgebrochen. Änderungen an `.env`, Basis-Compose oder der Override-Datei während des Downloads führen ebenfalls vor der Wartungsphase zum Abbruch. 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.
Während des ersten Ausbaus den Befehl in einer bestehenden persistenten Terminalsitzung ausführen. Der spätere Host-Dienst übernimmt die vom Browser unabhängige Ausführung. Ein Neustart des FEDEO-Stacks beendet diesen Host-Prozess nicht; ein Neustart des Hosts unterbricht ihn. 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 spätere Backend-Anbindung muss Systemadministratoren prüfen und darf nur diese kontrollierte Schnittstelle verwenden. Es wird kein Docker-Socket in Backend oder Frontend eingebunden. Dieser Stand richtet noch keine Backend-Route und keinen Frontend-Button ein.
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.
## 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 ## Ablauf und Speicherung
@@ -59,6 +130,7 @@ Während des ersten Ausbaus den Befehl in einer bestehenden persistenten Termina
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. 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. 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. 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. 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. 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. 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.
@@ -86,6 +158,8 @@ Für die Rückkehr zum alten Stand in einem geplanten Wartungsfenster:
sudo python3 /opt/fedeo/scripts/selfhost-update.py resolve --version 3.0.0 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. `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 ## Entwicklung und Tests
@@ -95,4 +169,4 @@ npm test
npm run commits:check npm run commits:check
``` ```
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. Ein vollständiger Test mit realem PostgreSQL-/MinIO-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich. Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import dayjs from "dayjs" import dayjs from "dayjs"
import { getBankAllocationAmounts, isDirectBankAllocation } from "~/utils/bankAllocationTax"
const props = defineProps({ const props = defineProps({
item: { item: {
@@ -12,6 +13,8 @@ const loading = ref(true)
const incomingInvoices = ref([]) const incomingInvoices = ref([])
const createddocuments = ref([]) const createddocuments = ref([])
const costcentres = ref([]) const costcentres = ref([])
const bankAllocations = ref([])
const accounts = ref([])
const selectedYear = ref(String(dayjs().year())) const selectedYear = ref(String(dayjs().year()))
const selectedMonth = ref("all") const selectedMonth = ref("all")
@@ -74,8 +77,10 @@ const relevantCostCentreIds = computed(() => {
const yearItems = computed(() => { const yearItems = computed(() => {
const years = [...new Set( const years = [...new Set(
incomingInvoices.value [...incomingInvoices.value.map(invoice => invoice.date),
.map((invoice) => invoice.date ? String(dayjs(invoice.date).year()) : null) ...createddocuments.value.map(document => document.documentDate),
...bankAllocations.value.map(allocation => allocation.bankstatement?.date)]
.map((date) => date ? String(dayjs(date).year()) : null)
.filter(Boolean) .filter(Boolean)
)].sort((a, b) => Number(b) - Number(a)) )].sort((a, b) => Number(b) - Number(a))
@@ -183,7 +188,33 @@ const reportRows = computed(() => {
.filter(Boolean) .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(() => { const totals = computed(() => {
@@ -212,8 +243,12 @@ const setupPage = async () => {
loading.value = true loading.value = true
costcentres.value = await useEntities("costcentres").select("*", null, false, 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 invoices = await useEntities("incominginvoices").select("*, vendor(id,name)")
const documents = await useEntities("createddocuments").select("*, customer(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) => incomingInvoices.value = invoices.filter((invoice) =>
(invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre)) (invoice.accounts || []).some((account) => relevantCostCentreIds.value.has(account.costCentre))
@@ -283,7 +318,10 @@ setupPage()
class="w-full" class="w-full"
> >
<template #reference-cell="{ row }"> <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>
<template #date-cell="{ row }"> <template #date-cell="{ row }">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from "dayjs" import dayjs from "dayjs"
import { getBankAllocationTaxBreakdown } from "~/utils/bankAllocationTax"
import { import {
getCreatedDocumentTaxBreakdown, getCreatedDocumentTaxBreakdown,
getIncomingInvoiceTaxBreakdown getIncomingInvoiceTaxBreakdown
@@ -125,6 +126,12 @@ const loadSummary = async () => {
return sum + breakdown.tax19 + breakdown.tax7 return sum + breakdown.tax19 + breakdown.tax7
}, 0) }, 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 const expenses = invoiceExpenses + directAccountExpenses + depreciations
summary.value = { summary.value = {
@@ -133,7 +140,7 @@ const loadSummary = async () => {
expenses: Number(expenses.toFixed(2)), expenses: Number(expenses.toFixed(2)),
depreciations: Number(depreciations.toFixed(2)), depreciations: Number(depreciations.toFixed(2)),
result: Number((income - Math.abs(expenses)).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, incomeCount: outputDocs.length,
expenseCount: inputDocs.filter((invoice: any) => getIncomingInvoiceImmediateExpenseNet(invoice) > 0).length + directAccountAllocations.filter((allocation: any) => getStatementAllocationImmediateExpenseAmount(allocation) > 0).length, expenseCount: inputDocs.filter((invoice: any) => getIncomingInvoiceImmediateExpenseNet(invoice) > 0).length + directAccountAllocations.filter((allocation: any) => getStatementAllocationImmediateExpenseAmount(allocation) > 0).length,
depreciationCount: depreciationRows.length depreciationCount: depreciationRows.length

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script setup> <script setup>
import dayjs from "dayjs"; import dayjs from "dayjs";
import { BANK_ALLOCATION_TAX_ITEMS, getBankAllocationAmounts } from "~/utils/bankAllocationTax"
import { import {
DEPRECIATION_METHOD_ITEMS, DEPRECIATION_METHOD_ITEMS,
EXPENSE_BOOKING_MODE_ITEMS, EXPENSE_BOOKING_MODE_ITEMS,
@@ -36,6 +37,7 @@ const createddocuments = ref([])
const incominginvoices = ref([]) const incominginvoices = ref([])
const accounts = ref([]) const accounts = ref([])
const ownaccounts = ref([]) const ownaccounts = ref([])
const costcentres = ref([])
const loading = ref(true) const loading = ref(true)
const loadingDocuments = ref(true) const loadingDocuments = ref(true)
@@ -76,15 +78,17 @@ const setup = async () => {
useEntities("vendors").select() useEntities("vendors").select()
]) ])
const [statement, accountItems] = await Promise.all([ const [statement, accountItems, costCentreItems] = await Promise.all([
route.params.id route.params.id
? useEntities("bankstatements").selectSingle(route.params.id, "*, statementallocations(*, cd_id(*), ii_id(*))") ? useEntities("bankstatements").selectSingle(route.params.id, "*, statementallocations(*, cd_id(*), ii_id(*))")
: Promise.resolve(itemInfo.value), : Promise.resolve(itemInfo.value),
useEntities("accounts").selectSpecial("*", "number", true) useEntities("accounts").selectSpecial("*", "number", true),
useEntities("costcentres").select()
]) ])
itemInfo.value = statement itemInfo.value = statement
accounts.value = accountItems accounts.value = accountItems
costcentres.value = costCentreItems
if (itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value)) if (itemInfo.value) oldItemInfo.value = JSON.parse(JSON.stringify(itemInfo.value))
manualAllocationSum.value = calculateOpenSum.value manualAllocationSum.value = calculateOpenSum.value
@@ -164,6 +168,21 @@ const selectAccount = (id) => {
const manualAllocationSum = ref(itemInfo.value.amount || 0) const manualAllocationSum = ref(itemInfo.value.amount || 0)
const allocationDescription = ref("") 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 allocationBookingMode = ref("expense")
const allocationDepreciationMonths = ref(36) const allocationDepreciationMonths = ref(36)
const allocationDepreciationStartDate = ref(dayjs().format("YYYY-MM-DD")) const allocationDepreciationStartDate = ref(dayjs().format("YYYY-MM-DD"))
@@ -179,6 +198,10 @@ const isDepreciationAllocation = computed(() => isDepreciationBookingMode(alloca
const buildAllocationPayload = (payload) => { const buildAllocationPayload = (payload) => {
const base = { const base = {
...payload, ...payload,
...((payload.account || payload.ownaccount) ? {
costcentre: allocationCostcentre.value || null,
datevTaxKey: allocationTaxKey.value || null,
} : {}),
bookingMode: normalizeExpenseBookingMode(allocationBookingMode.value) bookingMode: normalizeExpenseBookingMode(allocationBookingMode.value)
} }
@@ -232,6 +255,8 @@ const saveAllocation = async (allocation) => {
vendorAccountToSave.value = null vendorAccountToSave.value = null
customerAccountToSave.value = null customerAccountToSave.value = null
ownAccountToSave.value = null ownAccountToSave.value = null
allocationCostcentre.value = null
allocationTaxKey.value = null
allocationBookingMode.value = "expense" allocationBookingMode.value = "expense"
allocationDepreciationMonths.value = 36 allocationDepreciationMonths.value = 36
allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD") allocationDepreciationStartDate.value = dayjs(itemInfo.value?.date || new Date()).format("YYYY-MM-DD")
@@ -696,6 +721,11 @@ setup()
{{ getAllocationLabel(item) }} {{ getAllocationLabel(item) }}
</div> </div>
<div class="text-xs text-gray-500 mt-0.5" v-if="item.description">{{ item.description }}</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'"> <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="warning" variant="soft">{{ getAllocationBookingModeLabel(item) }}</UBadge>
<UBadge size="xs" color="neutral" variant="soft" v-if="item.depreciationGroup">{{ item.depreciationGroup }}</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"> 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="grid grid-cols-12 gap-4 items-end">
<div class="col-span-12 md:col-span-3"> <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"> <UInput v-model="manualAllocationSum" type="number" step="0.01">
<template #trailing><span class="text-gray-500 text-xs">EUR</span></template> <template #trailing><span class="text-gray-500 text-xs">EUR</span></template>
</UInput> </UInput>
@@ -778,7 +808,7 @@ setup()
<UButton <UButton
icon="i-heroicons-check" icon="i-heroicons-check"
label="Buchen" label="Buchen"
:disabled="!accountToSave" :disabled="!accountToSave || !allocationTaxKey"
@click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })" @click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, account: accountToSave, description: allocationDescription })"
/> />
</div> </div>
@@ -793,6 +823,28 @@ setup()
</div> </div>
</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" <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"> 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"> <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
@@ -866,6 +918,7 @@ setup()
</USelectMenu> </USelectMenu>
<div class="md:col-span-3 flex justify-end"> <div class="md:col-span-3 flex justify-end">
<UButton size="xs" label="Zuweisen" <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 })"/> @click="saveAllocation({bankstatement: itemInfo.id, amount: manualAllocationSum, ownaccount: ownAccountToSave ? ownAccountToSave : null, customer: customerAccountToSave ? customerAccountToSave : null, vendor: vendorAccountToSave ? vendorAccountToSave : null, description: allocationDescription })"/>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,9 @@ export function releaseFiles(manifest, notes) {
['selfhost-install.sh', Buffer.from(readFileSync('scripts/selfhost-install.sh', 'utf8').replace('FEDEO_REF:-dev', `FEDEO_REF:-${manifest.tag}`))], ['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-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.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'))], ['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')); files.set('SHA256SUMS', Buffer.from([...files].map(([name, data]) => `${createHash('sha256').update(data).digest('hex')} ${name}`).join('\n') + '\n'));

View File

@@ -152,6 +152,10 @@ test('release assets pin compose, environment and installer to one version', ()
assert.doesNotMatch(files.get('docker-compose.selfhost.yml').toString(), /FEDEO_VERSION:-dev/); assert.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('.env.example').toString(), /^FEDEO_VERSION=1\.2\.0$/m);
assert.match(files.get('selfhost-install.sh').toString(), /FEDEO_REF:-v1\.2\.0/); 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); assert.equal(files.get('SHA256SUMS').toString().trim().split('\n').length, files.size - 1);
}); });

View File

@@ -186,6 +186,9 @@ download_selfhost_files() {
download_file "$FEDEO_RAW_BASE_URL/.env.example" "$FEDEO_DIR/.env.example" 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-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.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" download_file "$FEDEO_RAW_BASE_URL/docs/selfhost-updates.md" "$FEDEO_DIR/SELFHOST-UPDATES.md"
chmod +x "$FEDEO_DIR/scripts/selfhost-setup.sh" chmod +x "$FEDEO_DIR/scripts/selfhost-setup.sh"
} }

View File

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

View File

@@ -0,0 +1,373 @@
#!/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)
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()

View File

@@ -29,12 +29,18 @@ 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") 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") FILES = ("release-manifest.json", "docker-compose.selfhost.yml", ".env.example", "selfhost-setup.sh", "CHANGELOG.md")
GIB = 1024 ** 3 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): class UpdateError(Exception):
pass pass
class PlanChangedError(UpdateError):
pass
def now(): def now():
return datetime.datetime.now(datetime.timezone.utc).isoformat() return datetime.datetime.now(datetime.timezone.utc).isoformat()
@@ -286,7 +292,7 @@ class Updater:
if shutil.disk_usage(directory).free < required: if shutil.disk_usage(directory).free < required:
raise UpdateError(f"Zu wenig freier Speicher auf {directory}: mindestens {required // GIB + 1} GiB erforderlich.") raise UpdateError(f"Zu wenig freier Speicher auf {directory}: mindestens {required // GIB + 1} GiB erforderlich.")
def prepare(self, version): def prepare(self, version, *, allow_current=False):
for name in (".env", "docker-compose.yml"): for name in (".env", "docker-compose.yml"):
path = self.root / name path = self.root / name
if not path.is_file() or path.is_symlink(): if not path.is_file() or path.is_symlink():
@@ -316,7 +322,8 @@ class Updater:
self.target = self.manifest["version"] self.target = self.manifest["version"]
self.save("validating", version=self.target, revision=self.manifest["commit"]) self.save("validating", version=self.target, revision=self.manifest["commit"])
old_version = self.current["services"]["backend"].get("environment", {}).get("FEDEO_VERSION", "dev") old_version = self.current["services"]["backend"].get("environment", {}).get("FEDEO_VERSION", "dev")
if VERSION.fullmatch(old_version) and tuple(map(int, old_version.split("."))) >= tuple(map(int, self.target.split("."))): 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.") raise UpdateError("Zielversion muss neuer sein; Downgrades und erneute Installation sind gesperrt.")
previous = read_json(self.home / "installed.json", {}) previous = read_json(self.home / "installed.json", {})
write_json(self.job / "original-installed.json", previous) write_json(self.job / "original-installed.json", previous)
@@ -346,6 +353,18 @@ class Updater:
ids = self.compose(False, "ps", "--all", "--quiet").split() ids = self.compose(False, "ps", "--all", "--quiet").split()
self.containers = [self.inspect(container) for container in ids] self.containers = [self.inspect(container) for container in ids]
write_json(self.job / "original-containers.json", self.containers) 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): 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.") 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 self.running = [c["Config"]["Labels"]["com.docker.compose.service"] for c in self.containers
@@ -360,19 +379,42 @@ class Updater:
'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT pg_database_size(current_database())"').strip()) '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]) minio_size = int(self.runner.run(["du", "-sb", str(self.root / "minio")]).split()[0])
self.backup_size = database_size * 2 + minio_size + GIB 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) self.check_space(self.backup_size, self.home)
plan = {"version": self.target, "revision": self.manifest["commit"], "previousVersion": old_version, plan = {"version": self.target, "revision": self.manifest["commit"], "previousVersion": old_version,
"images": self.images, "composeDiff": str(self.job / "compose.diff"), "images": self.images, "composeDiff": str(self.job / "compose.diff"),
"releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed, "releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed,
"estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"])} "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) write_json(self.job / "plan.json", plan)
return plan return plan
def validate_configs(self): def validate_configs(self):
old, new = self.current["services"], self.candidate["services"] old, new = self.current["services"], self.candidate["services"]
required = {"backend", "frontend", "db", "minio", "createbuckets", "traefik", "node-exporter"} required = {"backend", "frontend", "db", "minio", "createbuckets", "traefik", "node-exporter"}
if set(old) != required or set(new) != required: differences = []
raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalen Daten und MinIO.") 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: for name in required:
if old[name].get("profiles") or new[name].get("profiles") or new[name].get("build"): 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.") raise UpdateError("Build-Kontexte und Compose-Profile werden nicht unterstützt.")
@@ -426,7 +468,46 @@ class Updater:
if value is None or (value == "" and key in ("DOMAIN", "POSTGRES_PASSWORD", "COOKIE_SECRET", "JWT_SECRET", "ENCRYPTION_KEY")): 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}.") 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): def backup(self):
self.verify_matrix_stopped()
self.validate_legacy_matrix()
directory = self.job / "backup" directory = self.job / "backup"
directory.mkdir(mode=0o700) directory.mkdir(mode=0o700)
for name in (".env", "docker-compose.yml", ".env.example", "scripts/selfhost-setup.sh"): for name in (".env", "docker-compose.yml", ".env.example", "scripts/selfhost-setup.sh"):
@@ -440,7 +521,7 @@ class Updater:
self.compose(False, "exec", "-T", "db", "pg_restore", "--list", input_file=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", self.compose(False, "exec", "-T", "db", "sh", "-ec",
'exec pg_dumpall -U "$POSTGRES_USER" --globals-only', output=directory / "globals.sql") 'exec pg_dumpall -U "$POSTGRES_USER" --globals-only', output=directory / "globals.sql")
paths = ["minio"] paths = ["minio", *self.matrix_backup_paths]
if (self.root / "traefik/letsencrypt").is_dir(): if (self.root / "traefik/letsencrypt").is_dir():
paths.append("traefik/letsencrypt") paths.append("traefik/letsencrypt")
self.runner.run(["tar", "-cpf", str(directory / "files.tar"), "-C", str(self.root), *paths], timeout=7200) self.runner.run(["tar", "-cpf", str(directory / "files.tar"), "-C", str(self.root), *paths], timeout=7200)
@@ -454,7 +535,10 @@ class Updater:
digest.update(chunk) digest.update(chunk)
os.fsync(stream.fileno()) os.fsync(stream.fileno())
checksums[str(path.relative_to(directory))] = digest.hexdigest() checksums[str(path.relative_to(directory))] = digest.hexdigest()
write_json(directory / "complete.json", {"createdAt": now(), "database": "database.dump", "files": "files.tar", "sha256": checksums}) 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)) self.save("backed_up", backup=str(directory))
def verify(self, version, revision=None): def verify(self, version, revision=None):
@@ -487,6 +571,13 @@ class Updater:
raise UpdateError("Konfiguration wurde während des Backups verändert; erneut prüfen.") 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. # From here recovery must be explicit: migrations or infrastructure can alter persisted data.
self.save("applying", mutationStarted=True) 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 / "docker-compose.yml", (self.job / "target.compose.yml").read_bytes())
atomic(self.root / ".env", (self.job / "target.env").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 / ".env.example", (self.job / ".env.example").read_bytes())
@@ -500,6 +591,7 @@ class Updater:
self.save("starting") self.save("starting")
self.compose(True, "up", "-d", "--wait", "--wait-timeout", "600", timeout=900) self.compose(True, "up", "-d", "--wait", "--wait-timeout", "600", timeout=900)
self.save("verifying") self.save("verifying")
self.verify_matrix_stopped(retired=True)
for name, image in self.images.items(): for name, image in self.images.items():
container_id = self.compose(True, "ps", "--quiet", name).strip() container_id = self.compose(True, "ps", "--quiet", name).strip()
container = self.inspect(container_id) container = self.inspect(container_id)
@@ -510,7 +602,7 @@ class Updater:
write_json(self.home / "installed.json", {"manifest": self.manifest, "composeSha256": sha((self.root / "docker-compose.yml").read_bytes()), "installedAt": now()}) 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) self.save("complete", status="succeeded", recoveryRequired=False, maintenance=False)
def execute(self, command, version=None, accept_compose=False): def execute(self, command, version=None, accept_compose=False, *, expected_fingerprint=None, request_id=None):
with self.lock(): with self.lock():
previous = read_json(self.home / "status.json", {}) previous = read_json(self.home / "status.json", {})
if previous.get("recoveryRequired"): if previous.get("recoveryRequired"):
@@ -519,9 +611,13 @@ class Updater:
self.job.mkdir(mode=0o700) self.job.mkdir(mode=0o700)
self.state = {"status": "running", "phase": "preparing", "job": str(self.job), "command": command, self.state = {"status": "running", "phase": "preparing", "job": str(self.job), "command": command,
"startedAt": now(), "recoveryRequired": False, "mutationStarted": False} "startedAt": now(), "recoveryRequired": False, "mutationStarted": False}
if request_id:
self.state["requestId"] = request_id
self.save() self.save()
try: try:
plan = self.prepare(version) 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": if command == "check":
self.save("checked", status="checked") self.save("checked", status="checked")
else: else:
@@ -585,10 +681,22 @@ class Updater:
if not expected or container.get("Image") != expected: if not expected or container.get("Image") != expected:
raise UpdateError(f"{name} entspricht weder dem gewählten Zielrelease noch dem gesicherten Originalstand.") raise UpdateError(f"{name} entspricht weder dem gewählten Zielrelease noch dem gesicherten Originalstand.")
if version == manifest["version"]: if version == manifest["version"]:
self.verify_matrix_stopped(retired=True)
self.verify(version, manifest["commit"]) self.verify(version, manifest["commit"])
write_json(self.home / "installed.json", {"manifest": manifest, write_json(self.home / "installed.json", {"manifest": manifest,
"composeSha256": sha((self.job / "target.compose.yml").read_bytes()), "installedAt": now()}) "composeSha256": sha((self.job / "target.compose.yml").read_bytes()), "installedAt": now()})
else: 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", {}) previous = read_json(self.job / "original-installed.json", {})
if previous: if previous:
write_json(self.home / "installed.json", previous) write_json(self.home / "installed.json", previous)

View File

@@ -0,0 +1,268 @@
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
status, _ = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.2.0'})
self.assertEqual(status, 200)
self.assertEqual(len(self.factory.calls), 1)
status, result = self.request('POST', '/v1/check', {'requestId': request_id, 'version': '3.3.0'})
self.assertEqual(status, 409)
self.assertEqual(result['error']['code'], 'REQUEST_CONFLICT')
def test_concurrent_submissions_start_exactly_one_worker(self):
self.factory.release.clear()
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
responses = list(pool.map(lambda i: self.request('POST', '/v1/check', {'requestId': f'parallel-{i}'}), range(2)))
self.assertEqual(sorted(r[0] for r in responses), [202, 409])
self.assertTrue(self.factory.started.wait(3))
self.assertEqual(len(self.factory.calls), 1)
def test_apply_requires_reviewed_version_and_passes_private_fingerprint(self):
body = {'requestId': 'apply-request-01', 'version': '3.2.0', 'checkRequestId': 'check-request-01'}
self.assertEqual(self.request('POST', '/v1/apply', body)[1]['error']['code'], 'CHECK_REQUIRED')
self.check()
self.assertEqual(self.request('POST', '/v1/apply', {**body, 'version': '3.3.0'})[0], 409)
self.assertEqual(self.request('POST', '/v1/apply', body)[0], 202)
self.finish()
self.assertEqual(self.factory.calls[-1], ('apply', '3.2.0', 'a' * 64, 'apply-request-01'))
self.assertEqual(self.request('GET', '/v1/requests/apply-request-01')[1]['status'], 'succeeded')
def test_unmanaged_compose_and_changed_plan_cannot_be_applied_remotely(self):
self.factory.adoption = True
self.check()
body = {'requestId': 'apply-request-01', 'version': '3.2.0', 'checkRequestId': 'check-request-01'}
self.assertEqual(self.request('POST', '/v1/apply', body)[1]['error']['code'], 'COMPOSE_REVIEW_REQUIRED')
self.factory.adoption = False
self.check('check-request-02')
self.factory.fingerprint = 'b' * 64
self.assertEqual(self.request('POST', '/v1/apply', {**body, 'checkRequestId': 'check-request-02'})[0], 202)
self.finish()
self.assertEqual(self.request('GET', '/v1/requests/apply-request-01')[1]['error']['code'], 'PLAN_CHANGED')
def test_already_current_is_a_successful_check_without_apply(self):
self.factory.available = False
self.check()
self.assertEqual(self.request('GET', '/v1/requests/check-request-01')[1]['status'], 'up_to_date')
body = {'requestId': 'apply-request-01', 'version': '3.2.0', 'checkRequestId': 'check-request-01'}
self.assertEqual(self.request('POST', '/v1/apply', body)[0], 409)
def test_restart_marks_pending_requests_interrupted_without_replaying(self):
for status in ('queued', 'running'):
s.engine.write_json(self.controller.path('abandoned-' + status),
{'requestId': 'abandoned-' + status, 'command': 'apply', 'status': status})
self.controller.close()
self.controller = s.Controller(self.root, updater_factory=self.factory)
self.server.controller = self.controller
for status in ('queued', 'running'):
record = self.request('GET', '/v1/requests/abandoned-' + status)[1]
self.assertEqual(record['status'], 'interrupted')
self.assertEqual(self.factory.calls, [])
def test_shutdown_drains_worker_and_rejects_new_work(self):
self.factory.release.clear()
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'})[0], 202)
self.assertTrue(self.factory.started.wait(3))
closing = threading.Thread(target=self.controller.close)
closing.start()
# Acquire the gate after close has entered it, without relying on a wall-clock sleep.
for _ in range(1000):
with self.controller.gate:
if self.controller.closing:
break
threading.Event().wait(0.001)
self.assertTrue(closing.is_alive())
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-02'})[1]['error']['code'], 'SHUTTING_DOWN')
self.factory.release.set()
closing.join(3)
self.assertFalse(closing.is_alive())
self.assertEqual(self.controller.request_status('check-request-01')['status'], 'checked')
def test_cli_lock_and_recovery_block_new_requests(self):
updater = self.factory(self.root)
job = updater.home / 'cli-job'
job.mkdir()
s.engine.write_json(updater.home / 'status.json', {'status': 'running', 'job': str(job)})
with updater.lock():
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'})[1]['error']['code'], 'BUSY')
s.engine.write_json(updater.home / 'status.json', {'status': 'failed', 'recoveryRequired': True})
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'})[1]['error']['code'], 'RECOVERY_REQUIRED')
def test_status_never_returns_raw_errors_paths_logs_or_configuration_fingerprints(self):
self.factory.failure = ValueError('password-secret /opt/private/.env')
self.check()
result = self.request('GET', '/v1/requests/check-request-01')[1]
self.assertEqual(result['error']['code'], 'UPDATE_FAILED')
self.assertNotIn('password-secret', json.dumps(result))
self.factory.failure = None
self.check('check-request-02')
payload = json.dumps(self.request('GET', '/v1/requests/check-request-02')[1])
self.assertNotIn('/private', payload)
self.assertNotIn('approvalFingerprint', payload)
self.assertNotIn('a' * 64, payload)
self.assertEqual(self.request('GET', '/v1/requests/../../secret')[0], 400)
self.assertEqual(self.request('GET', '/v1/logs')[0], 404)
self.assertEqual((self.controller.home / 'check-request-01.log').stat().st_mode & 0o777, 0o600)
def test_api_rejects_unsupported_commands_options_bodies_and_browser_requests(self):
for body in [[], {'requestId': 'bad/path'}, {'requestId': 'check-request-01', 'version': ';reboot'},
{'requestId': 'check-request-01', 'directory': '/etc'},
{'requestId': 'check-request-01', 'acceptComposeReplacement': True}]:
with self.subTest(body=body):
self.assertEqual(self.request('POST', '/v1/check', body)[0], 400)
self.assertEqual(self.request('POST', '/v1/resolve', {'requestId': 'resolve-request'})[0], 404)
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01'},
{'Content-Type': 'application/json', 'Origin': 'https://example.test'})[0], 400)
self.assertEqual(self.request('POST', '/v1/check', {'requestId': 'check-request-01', 'padding': 'x' * 5000})[0], 400)
self.assertEqual(self.factory.calls, [])
def test_second_controller_cannot_take_over_active_instance(self):
self.assertEqual(self.socket.stat().st_mode & 0o777, 0o660)
with self.assertRaisesRegex(s.engine.UpdateError, 'bereits ein Update-Dienst'):
s.Controller(self.root, updater_factory=self.factory)
class InstallerTests(unittest.TestCase):
def test_unit_render_is_read_only_and_escapes_systemd_expansions(self):
installer = ROOT / 'scripts/selfhost-update-service-install.sh'
subprocess.run(['bash', '-n', str(installer)], check=True)
result = subprocess.run(['bash', str(installer), '--print-unit', '--directory', '/opt/fedeo test%$"'],
text=True, capture_output=True, check=True)
self.assertIn('serve --directory "/opt/fedeo test%%$$\\\""', result.stdout)
self.assertIn('KillMode=mixed', result.stdout)
self.assertIn('TimeoutStopSec=infinity', result.stdout)
self.assertNotIn('@FEDEO_DIRECTORY@', result.stdout)
invalid = subprocess.run(['bash', str(installer), '--print-unit', '--directory', '/opt/fedeo\nExecStart=reboot'],
capture_output=True)
self.assertNotEqual(invalid.returncode, 0)
if __name__ == '__main__':
unittest.main()

View File

@@ -6,6 +6,7 @@ import os
import io import io
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import tarfile
import tempfile import tempfile
import unittest import unittest
from unittest.mock import patch from unittest.mock import patch
@@ -35,6 +36,7 @@ class Http:
self.bad_health = False self.bad_health = False
self.release = {'id': 1, 'tag_name': 'v3.1.0', 'draft': False, 'prerelease': False} self.release = {'id': 1, 'tag_name': 'v3.1.0', 'draft': False, 'prerelease': False}
self.fail_download = False self.fail_download = False
self.reported_version = '3.1.0'
def refresh(self): def refresh(self):
self.files['SHA256SUMS'] = ''.join(hashlib.sha256(blob).hexdigest() + ' ' + name + '\n' self.files['SHA256SUMS'] = ''.join(hashlib.sha256(blob).hexdigest() + ' ' + name + '\n'
@@ -59,7 +61,7 @@ class Http:
if url.endswith('/tags/v3.1.0'): if url.endswith('/tags/v3.1.0'):
return {'commit': {'sha': 'a' * 40}} return {'commit': {'sha': 'a' * 40}}
if url.endswith('/backend/version'): if url.endswith('/backend/version'):
return {'product': 'FEDEO', 'version': '3.1.0'} return {'product': 'FEDEO', 'version': self.reported_version}
if url.endswith('/health'): if url.endswith('/health'):
return {'status': 'ok', 'product': 'FEDEO', 'version': '3.0.0' if self.bad_health else '3.1.0', 'revision': 'a' * 40} return {'status': 'ok', 'product': 'FEDEO', 'version': '3.0.0' if self.bad_health else '3.1.0', 'revision': 'a' * 40}
raise AssertionError(url) raise AssertionError(url)
@@ -74,6 +76,9 @@ class Runner:
self.fail = None self.fail = None
self.on_pull = None self.on_pull = None
self.interrupt = 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): def run(self, args, output=None, input_file=None, **kwargs):
self.calls.append(args) self.calls.append(args)
@@ -91,8 +96,13 @@ class Runner:
return 'image-' + args[3].rsplit('/', 1)[-1].split('@')[0] return 'image-' + args[3].rsplit('/', 1)[-1].split('@')[0]
if args[:2] == ['docker', 'inspect']: if args[:2] == ['docker', 'inspect']:
name = args[2] 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}}, return json.dumps([{'Image': 'image-' + name, 'Config': {'Labels': {'com.docker.compose.service': name}},
'State': {'Running': name != 'createbuckets', 'Health': {'Status': 'healthy'}}}]) '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: if 'config' in args:
result = subprocess.run(args, env={'PATH': os.environ['PATH'], 'HOME': os.environ['HOME']}, result = subprocess.run(args, env={'PATH': os.environ['PATH'], 'HOME': os.environ['HOME']},
capture_output=True, text=True) capture_output=True, text=True)
@@ -102,15 +112,25 @@ class Runner:
if 'ps' in args: if 'ps' in args:
if args[:2] == ['docker', 'ps']: if args[:2] == ['docker', 'ps']:
return '' return ''
if args[-1] in ('backend', 'frontend', 'db', 'minio'): if args[-1] in ('backend', 'frontend', 'db', 'minio') or args[-1] in self.matrix_containers:
return args[-1] return args[-1]
return 'backend\nfrontend\ndb\nminio\ntraefik\nnode-exporter\ncreatebuckets\n' 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: if 'pull' in args and self.on_pull:
self.on_pull() self.on_pull()
if any('pg_database_size' in part for part in args): if any('pg_database_size' in part for part in args):
return '1000' return '1000'
if args[0] == 'du': if args[0] == 'du':
return '1000\tminio' 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: if output:
Path(output).write_bytes(b'PGDMP fake dump') Path(output).write_bytes(b'PGDMP fake dump')
if args[0] == 'tar' and '-cpf' in args: if args[0] == 'tar' and '-cpf' in args:
@@ -153,6 +173,196 @@ class UpdateTests(unittest.TestCase):
self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose) self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose)
self.assertFalse(self.called('stop')) 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): def test_success_pins_images_preserves_configuration_and_checks_both_public_services(self):
result = self.run_update() result = self.run_update()
self.assertEqual(result['status'], 'succeeded') self.assertEqual(result['status'], 'succeeded')
@@ -357,6 +567,37 @@ class UpdateTests(unittest.TestCase):
self.run_update() self.run_update()
self.assertFalse(self.called('stop')) 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): def test_backup_integrity_manifest_is_complete(self):
result = self.run_update() result = self.run_update()
backup = Path(result['backup']) backup = Path(result['backup'])