Merge pull request 'dev' (#227) from dev into main
Reviewed-on: #227
This commit was merged in pull request #227.
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "statementallocations"
|
||||||
|
ADD COLUMN "costcentre" uuid REFERENCES "costcentres"("id");
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
30
backend/src/modules/bank-allocation-details.ts
Normal file
30
backend/src/modules/bank-allocation-details.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { and, eq } from "drizzle-orm"
|
||||||
|
import { costcentres } from "../../db/schema"
|
||||||
|
|
||||||
|
const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 })
|
||||||
|
|
||||||
|
export function normalizeBankAllocationDetails(payload: Record<string, any>) {
|
||||||
|
const costcentre = payload.costcentre ? String(payload.costcentre).trim() : null
|
||||||
|
const datevTaxKey = payload.datevTaxKey ? String(payload.datevTaxKey).trim() : null
|
||||||
|
if (costcentre && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(costcentre)) {
|
||||||
|
throw invalidInput("Die Kostenstelle muss eine gültige UUID sein.")
|
||||||
|
}
|
||||||
|
if (datevTaxKey && !/^\d{1,4}$/.test(datevTaxKey)) {
|
||||||
|
throw invalidInput("Der DATEV-Steuerschlüssel muss aus 1 bis 4 Ziffern bestehen.")
|
||||||
|
}
|
||||||
|
if (costcentre && (!payload.bankstatement || !(payload.account || payload.ownaccount)
|
||||||
|
|| payload.createddocument || payload.incominginvoice || payload.customer || payload.vendor)) {
|
||||||
|
throw invalidInput("Kostenstellen sind hier nur für direkte Bankbuchungen auf Buchungskonten zulässig.")
|
||||||
|
}
|
||||||
|
return { costcentre, datevTaxKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function validateBankAllocationCostCentre(db: any, tenantId: number, costcentre: string | null) {
|
||||||
|
if (!costcentre) return
|
||||||
|
const [record] = await db.select({ id: costcentres.id }).from(costcentres).where(and(
|
||||||
|
eq(costcentres.id, costcentre),
|
||||||
|
eq(costcentres.tenant, tenantId),
|
||||||
|
eq(costcentres.archived, false),
|
||||||
|
)).limit(1)
|
||||||
|
if (!record) throw invalidInput("Die Kostenstelle wurde nicht gefunden oder ist archiviert.")
|
||||||
|
}
|
||||||
@@ -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" })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;;;;"";;;;;;`);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
69
backend/tests/bankAllocationDetails.test.ts
Normal file
69
backend/tests/bankAllocationDetails.test.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
import test from "node:test"
|
||||||
|
import { PgDialect } from "drizzle-orm/pg-core"
|
||||||
|
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../src/modules/bank-allocation-details"
|
||||||
|
import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations"
|
||||||
|
import bankingRoutes from "../src/routes/banking"
|
||||||
|
import { statementallocations } from "../db/schema"
|
||||||
|
|
||||||
|
const costcentre = "57d31d62-11a2-47c5-b074-69f9c5ab8bba"
|
||||||
|
|
||||||
|
test("keeps cost centre and VAT key on the gross bank allocation through input preparation", () => {
|
||||||
|
const prepared = prepareStatementAllocationInput({ bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: " 9 " })
|
||||||
|
assert.equal(prepared.amount, -119)
|
||||||
|
assert.equal(prepared.costcentre, costcentre)
|
||||||
|
assert.equal(prepared.datevTaxKey, "9")
|
||||||
|
assert.deepEqual(normalizeBankAllocationDetails({ amount: -119, account: 13, bankstatement: 42 }), { costcentre: null, datevTaxKey: null })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects invalid cost centres, ambiguous document assignments and malformed tax keys", () => {
|
||||||
|
assert.throws(() => normalizeBankAllocationDetails({ costcentre: "invalid" }), /UUID/)
|
||||||
|
assert.throws(() => normalizeBankAllocationDetails({ costcentre, bankstatement: 1, account: 13, incominginvoice: 2 }), /direkte Bankbuchungen/)
|
||||||
|
assert.throws(() => normalizeBankAllocationDetails({ datevTaxKey: '9";evil' }), /Steuerschlüssel/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cost centre lookup requires the active tenant and excludes archived centres", async () => {
|
||||||
|
let condition: any
|
||||||
|
const query = { from: () => query, where: (value: any) => { condition = value; return query }, limit: async () => [] }
|
||||||
|
await assert.rejects(validateBankAllocationCostCentre({ select: () => query }, 7, costcentre), /nicht gefunden oder ist archiviert/)
|
||||||
|
const sql = new PgDialect().sqlToQuery(condition)
|
||||||
|
assert.match(sql.sql, /"costcentres"\."tenant"/)
|
||||||
|
assert.match(sql.sql, /"costcentres"\."archived"/)
|
||||||
|
assert.deepEqual(sql.params, [costcentre, 7, false])
|
||||||
|
await validateBankAllocationCostCentre({ select: () => ({ ...query, from: () => ({ where: () => ({ limit: async () => [{ id: costcentre }] }) }) }) }, 7, costcentre)
|
||||||
|
await validateBankAllocationCostCentre({}, 7, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("banking API persists the gross allocation with cost centre and VAT, and rejects unknown centres before writing", async () => {
|
||||||
|
const handlers = new Map<string, any>()
|
||||||
|
const saved: any[] = []
|
||||||
|
let centreExists = true
|
||||||
|
const lookup: any = { from: () => lookup, where: () => lookup, limit: async () => centreExists ? [{ id: costcentre }] : [] }
|
||||||
|
const server: any = {
|
||||||
|
get() {}, patch() {}, delete() {},
|
||||||
|
post: (url: string, handler: any) => handlers.set(url, handler),
|
||||||
|
db: {
|
||||||
|
select: () => lookup,
|
||||||
|
insert: (table: any) => ({ values: (value: any) => {
|
||||||
|
if (table === statementallocations) saved.push(value)
|
||||||
|
return { returning: async () => [{ id: "allocation", ...value }] }
|
||||||
|
} }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await bankingRoutes(server)
|
||||||
|
const handler = handlers.get("/banking/statements")
|
||||||
|
const request = { user: { tenant_id: 7, user_id: "user" }, body: { data: { bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: "9" } } }
|
||||||
|
let status = 200
|
||||||
|
let response: any
|
||||||
|
const reply = { code: (code: number) => { status = code; return reply }, send: (body: any) => { response = body; return reply } }
|
||||||
|
await handler(request, reply)
|
||||||
|
assert.equal(status, 200)
|
||||||
|
assert.equal(response.amount, -119)
|
||||||
|
assert.equal(response.costcentre, costcentre)
|
||||||
|
assert.equal(response.datevTaxKey, "9")
|
||||||
|
assert.equal(saved[0].tenant, 7)
|
||||||
|
centreExists = false
|
||||||
|
await handler(request, reply)
|
||||||
|
assert.equal(status, 400)
|
||||||
|
assert.equal(saved.length, 1)
|
||||||
|
})
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 }">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
63
frontend/tests/bank-allocation-tax.test.cjs
Normal file
63
frontend/tests/bank-allocation-tax.test.cjs
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
const { test } = require('node:test')
|
||||||
|
const assert = require('node:assert/strict')
|
||||||
|
const fs = require('node:fs')
|
||||||
|
const vm = require('node:vm')
|
||||||
|
const ts = require('typescript')
|
||||||
|
const path = require('node:path')
|
||||||
|
|
||||||
|
function loadModule(file) {
|
||||||
|
const filename = path.resolve(__dirname, file)
|
||||||
|
const module = { exports: {} }
|
||||||
|
const localRequire = id => id.startsWith('.') ? loadModule(path.relative(__dirname, path.resolve(path.dirname(filename), id + '.ts'))) : require(id)
|
||||||
|
vm.runInNewContext(ts.transpile(fs.readFileSync(filename, 'utf8'), { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true }), { module, exports: module.exports, require: localRequire })
|
||||||
|
return module.exports
|
||||||
|
}
|
||||||
|
const { getBankAllocationAmounts, getBankAllocationTaxBreakdown } = loadModule('../utils/bankAllocationTax.ts')
|
||||||
|
const { getStatementAccountExpenseTotal, getStatementAllocationDepreciationAmount } = loadModule('../composables/useDepreciation.ts')
|
||||||
|
const allocation = (amount, datevTaxKey, extra = {}) => ({ bankstatement: { id: 42 }, account: 13, amount, datevTaxKey, ...extra })
|
||||||
|
|
||||||
|
for (const [gross, key, net, tax, side, rate] of [
|
||||||
|
[-119, '9', -100, -19, 'input', 19], [-107, '8', -100, -7, 'input', 7],
|
||||||
|
[119, '3', 100, 19, 'output', 19], [107, '2', 100, 7, 'output', 7],
|
||||||
|
[59.5, '9', 50, 9.5, 'input', 19], [-59.5, '3', -50, -9.5, 'output', 19],
|
||||||
|
]) {
|
||||||
|
test(`gross ${gross} and key ${key} produce correct net, VAT and refund signs`, () => {
|
||||||
|
const item = allocation(gross, key)
|
||||||
|
const amounts = getBankAllocationAmounts(item)
|
||||||
|
assert.equal(amounts.amountGross, gross)
|
||||||
|
assert.equal(amounts.amountNet, net)
|
||||||
|
assert.equal(amounts.amountTax, tax)
|
||||||
|
const result = getBankAllocationTaxBreakdown(item)
|
||||||
|
assert.equal(result[side][`net${rate}`], net * (side === 'input' ? -1 : 1))
|
||||||
|
assert.equal(result[side][`tax${rate}`], tax * (side === 'input' ? -1 : 1))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
test('rounding preserves gross, and tax-free and legacy allocations retain their amounts', () => {
|
||||||
|
const amounts = getBankAllocationAmounts(allocation(-10, '9'))
|
||||||
|
assert.equal(amounts.amountNet, -8.4)
|
||||||
|
assert.equal(amounts.amountTax, -1.6)
|
||||||
|
for (const key of ['40', null]) {
|
||||||
|
const value = getBankAllocationAmounts(allocation(-100, key))
|
||||||
|
assert.equal(value.amountNet, -100)
|
||||||
|
assert.equal(value.amountTax, 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('invoice settlements, archived bookings and manual bookings are not counted again', () => {
|
||||||
|
for (const extra of [{ incominginvoice: 1 }, { createddocument: 1 }, { archived: true }, { bankstatement: null }]) {
|
||||||
|
const result = getBankAllocationTaxBreakdown(allocation(-119, '9', extra))
|
||||||
|
assert.ok([...Object.values(result.input), ...Object.values(result.output)].every(value => value === 0))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
test('additional accounts carry the same tax breakdown', () => {
|
||||||
|
assert.equal(getBankAllocationAmounts(allocation(-119, '9', { account: null, ownaccount: 'own' })).amountTax, -19)
|
||||||
|
})
|
||||||
|
test('BWA separates net expenses from gross cash flows including partial refunds', () => {
|
||||||
|
const items = [allocation(-119, '9'), allocation(59.5, '9')]
|
||||||
|
assert.equal(getStatementAccountExpenseTotal(items), 50)
|
||||||
|
assert.equal(getStatementAccountExpenseTotal(items, true), 59.5)
|
||||||
|
})
|
||||||
|
test('depreciation uses the net acquisition amount when input VAT is selected', () => {
|
||||||
|
assert.equal(getStatementAllocationDepreciationAmount(allocation(-1428, '9', {
|
||||||
|
bookingMode: 'depreciation_single', depreciationMonths: 12, depreciationStartDate: '2026-01-01',
|
||||||
|
}), '2026-01-01', '2026-01-31'), 100)
|
||||||
|
})
|
||||||
43
frontend/utils/bankAllocationTax.ts
Normal file
43
frontend/utils/bankAllocationTax.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export const BANK_ALLOCATION_TAX_ITEMS = [
|
||||||
|
{ value: "9", label: "19 % Vorsteuer", rate: 19, kind: "input" },
|
||||||
|
{ value: "8", label: "7 % Vorsteuer", rate: 7, kind: "input" },
|
||||||
|
{ value: "3", label: "19 % Umsatzsteuer", rate: 19, kind: "output" },
|
||||||
|
{ value: "2", label: "7 % Umsatzsteuer", rate: 7, kind: "output" },
|
||||||
|
{ value: "40", label: "Ohne Umsatzsteuer", rate: 0, kind: "none" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const isDirectBankAllocation = (allocation: any) => Boolean(
|
||||||
|
allocation?.bankstatement && (allocation.account || allocation.ownaccount)
|
||||||
|
&& !allocation.createddocument && !allocation.incominginvoice
|
||||||
|
&& !allocation.customer && !allocation.vendor && !allocation.archived
|
||||||
|
)
|
||||||
|
|
||||||
|
// The signed bank amount always remains gross. Refunds retain their sign.
|
||||||
|
export function getBankAllocationAmounts(allocation: any) {
|
||||||
|
const amountGross = Number(allocation?.amount || 0)
|
||||||
|
const tax = isDirectBankAllocation(allocation)
|
||||||
|
? BANK_ALLOCATION_TAX_ITEMS.find(item => item.value === String(allocation.datevTaxKey))
|
||||||
|
: undefined
|
||||||
|
const rate = tax?.rate || 0
|
||||||
|
const amountNet = Number((amountGross / (1 + rate / 100)).toFixed(2))
|
||||||
|
const amountTax = Number((amountGross - amountNet).toFixed(2))
|
||||||
|
return { amountGross, amountNet, amountTax, rate, kind: tax?.kind || "none" }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBankAllocationTaxBreakdown(allocation: any) {
|
||||||
|
const output = { net19: 0, tax19: 0, net7: 0, tax7: 0, net0: 0 }
|
||||||
|
const input = { ...output }
|
||||||
|
if (!isDirectBankAllocation(allocation)) return { output, input }
|
||||||
|
const { amountNet, amountTax, rate, kind } = getBankAllocationAmounts(allocation)
|
||||||
|
if (kind === "none") return { output, input }
|
||||||
|
const target = kind === "input" ? input : output
|
||||||
|
const sign = kind === "input" ? -1 : 1
|
||||||
|
if (rate === 19) {
|
||||||
|
target.net19 = amountNet * sign
|
||||||
|
target.tax19 = amountTax * sign
|
||||||
|
} else if (rate === 7) {
|
||||||
|
target.net7 = amountNet * sign
|
||||||
|
target.tax7 = amountTax * sign
|
||||||
|
}
|
||||||
|
return { output, input }
|
||||||
|
}
|
||||||
33
scripts/fedeo-updater.service
Normal file
33
scripts/fedeo-updater.service
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=FEDEO host update service
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Requires=docker.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=fedeo-updater
|
||||||
|
Environment=HOME=/root
|
||||||
|
Environment=PYTHONDONTWRITEBYTECODE=1
|
||||||
|
EnvironmentFile=-/etc/fedeo/updater.env
|
||||||
|
ExecStart=/usr/bin/python3 -B /usr/local/lib/fedeo-updater/selfhost-update-service.py serve --directory @FEDEO_DIRECTORY@
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5s
|
||||||
|
KillMode=mixed
|
||||||
|
TimeoutStopSec=infinity
|
||||||
|
UMask=0077
|
||||||
|
RuntimeDirectory=fedeo-updater
|
||||||
|
RuntimeDirectoryMode=0750
|
||||||
|
RuntimeDirectoryPreserve=yes
|
||||||
|
NoNewPrivileges=true
|
||||||
|
ProtectSystem=full
|
||||||
|
ProtectKernelTunables=true
|
||||||
|
ProtectKernelModules=true
|
||||||
|
ProtectControlGroups=true
|
||||||
|
RestrictSUIDSGID=true
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
93
scripts/selfhost-update-service-install.sh
Normal file
93
scripts/selfhost-update-service-install.sh
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
FEDEO_DIR=/opt/fedeo
|
||||||
|
PRINT_UNIT=false
|
||||||
|
SOURCE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--directory) FEDEO_DIR="${2:?Verzeichnis fehlt}"; shift 2 ;;
|
||||||
|
--print-unit) PRINT_UNIT=true; shift ;;
|
||||||
|
--help|-h) echo "sudo bash $0 [--directory /opt/fedeo] [--print-unit]"; exit 0 ;;
|
||||||
|
*) echo "Unbekanntes Argument: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
render_unit() {
|
||||||
|
python3 - "$SOURCE_DIR/fedeo-updater.service" "$FEDEO_DIR" <<'PY'
|
||||||
|
import pathlib, sys
|
||||||
|
directory = sys.argv[2]
|
||||||
|
if not directory.startswith('/') or any(c in directory for c in '\n\r\0'):
|
||||||
|
raise SystemExit('Ein gültiges absolutes Betriebsverzeichnis angeben.')
|
||||||
|
quoted = '"' + directory.replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%').replace('$', '$$') + '"'
|
||||||
|
sys.stdout.write(pathlib.Path(sys.argv[1]).read_text().replace('@FEDEO_DIRECTORY@', quoted))
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
if [[ "$PRINT_UNIT" == true ]]; then
|
||||||
|
render_unit
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ "$(id -u)" -eq 0 ]] || { echo "Als root ausführen." >&2; exit 1; }
|
||||||
|
[[ -d /run/systemd/system ]] || { echo "Ein Linux-Host mit laufendem systemd wird benötigt." >&2; exit 1; }
|
||||||
|
FEDEO_DIR="$(realpath -e -- "$FEDEO_DIR")"
|
||||||
|
[[ -f "$FEDEO_DIR/.env" && -f "$FEDEO_DIR/docker-compose.yml" && ! -e "$FEDEO_DIR/.git" ]] || {
|
||||||
|
echo "Ein bestehendes Selfhost-Betriebsverzeichnis ohne Git-Checkout angeben." >&2; exit 1;
|
||||||
|
}
|
||||||
|
python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 10) else "Python ab 3.10 wird benötigt.")'
|
||||||
|
for name in selfhost-update.py selfhost-update-service.py fedeo-updater.service; do
|
||||||
|
[[ -f "$SOURCE_DIR/$name" ]] || { echo "Fehlende Installationsdatei: $name" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
python3 - "$SOURCE_DIR" <<'PY'
|
||||||
|
import importlib.util, inspect, pathlib, sys
|
||||||
|
source = pathlib.Path(sys.argv[1])
|
||||||
|
spec = importlib.util.spec_from_file_location('fedeo_update_install_check', source / 'selfhost-update.py')
|
||||||
|
engine = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(engine)
|
||||||
|
if not {'expected_fingerprint', 'request_id'}.issubset(inspect.signature(engine.Updater.execute).parameters):
|
||||||
|
raise SystemExit('Updater und Dienst passen nicht zusammen. Alle Dateien aus demselben Stand bereitstellen.')
|
||||||
|
compile((source / 'selfhost-update-service.py').read_text(), 'selfhost-update-service.py', 'exec')
|
||||||
|
PY
|
||||||
|
command -v flock >/dev/null
|
||||||
|
command -v docker >/dev/null
|
||||||
|
command -v curl >/dev/null
|
||||||
|
docker compose version >/dev/null
|
||||||
|
UNIT_CONTENT="$(render_unit)"
|
||||||
|
|
||||||
|
# Drain an active service job before replacing either Python module. No containers are restarted here.
|
||||||
|
if systemctl is-active --quiet fedeo-updater.service; then
|
||||||
|
systemctl stop fedeo-updater.service
|
||||||
|
fi
|
||||||
|
[[ ! -L "$FEDEO_DIR/.updates" ]] || { echo ".updates darf kein symbolischer Link sein." >&2; exit 1; }
|
||||||
|
install -d -m 0700 "$FEDEO_DIR/.updates"
|
||||||
|
exec 9>"$FEDEO_DIR/.updates/lock"
|
||||||
|
flock -n 9 || { echo "Ein CLI-Update läuft noch. Dienstinstallation später wiederholen." >&2; exit 1; }
|
||||||
|
|
||||||
|
getent group fedeo-updater >/dev/null || groupadd --system fedeo-updater
|
||||||
|
install -d -o root -g root -m 0700 /usr/local/lib/fedeo-updater /etc/fedeo
|
||||||
|
install -o root -g root -m 0700 "$SOURCE_DIR/selfhost-update.py" /usr/local/lib/fedeo-updater/selfhost-update.py
|
||||||
|
install -o root -g root -m 0700 "$SOURCE_DIR/selfhost-update-service.py" /usr/local/lib/fedeo-updater/selfhost-update-service.py
|
||||||
|
if [[ ! -e /etc/fedeo/updater.env ]]; then
|
||||||
|
(umask 077; printf '%s\n' '# Optional: FEDEO_RELEASE_TOKEN=... (nur Leserechte)' > /etc/fedeo/updater.env)
|
||||||
|
fi
|
||||||
|
chmod 0600 /etc/fedeo/updater.env
|
||||||
|
UNIT_TMP="$(mktemp /etc/systemd/system/fedeo-updater.service.XXXXXX)"
|
||||||
|
trap 'rm -f -- "$UNIT_TMP"' EXIT
|
||||||
|
printf '%s\n' "$UNIT_CONTENT" > "$UNIT_TMP"
|
||||||
|
chmod 0644 "$UNIT_TMP"
|
||||||
|
mv -- "$UNIT_TMP" /etc/systemd/system/fedeo-updater.service
|
||||||
|
flock -u 9
|
||||||
|
exec 9>&-
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now fedeo-updater.service
|
||||||
|
READY=false
|
||||||
|
for attempt in {1..50}; do
|
||||||
|
if curl --silent --fail --max-time 1 --unix-socket /run/fedeo-updater/control.sock http://localhost/v1/status >/dev/null; then
|
||||||
|
READY=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
systemctl --no-pager --full status fedeo-updater.service
|
||||||
|
[[ "$READY" == true ]] || { echo "Dienstschnittstelle noch nicht erreichbar; journalctl -u fedeo-updater.service prüfen." >&2; exit 1; }
|
||||||
|
echo "Lokale Schnittstelle: /run/fedeo-updater/control.sock"
|
||||||
373
scripts/selfhost-update-service.py
Normal file
373
scripts/selfhost-update-service.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
268
scripts/update/tests/test_service.py
Normal file
268
scripts/update/tests/test_service.py
Normal 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()
|
||||||
@@ -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'])
|
||||||
|
|||||||
Reference in New Issue
Block a user