Source: Router/registry/contractImport.js

/**
 * Vertrags-Import — der Backend-Katalog aus dem Konfig-Bucket.
 *
 * Liest `config/contract/backends.yaml` aus dem Konfig-Bucket und schreibt ihn in
 * die Registry-Tabellen: die App-Ebene nach `AppCatalog`, die Zweige nach
 * `AppBackendBranch`. Beides über `registryService` — **direkt**, nicht über die
 * HTTP-Registry und nicht über den Broker. Der Vertrag ist members-back-Datenhoheit
 * (er füllt seine Tabellen), also importiert members-back ihn auch.
 *
 * ## Woher der Vertrag kommt
 *
 * Der Vertrag ist **global** (ein File für alle Organisationen, geschlüsselt über
 * `appKey`). Er liegt deshalb nicht im Orga-Baum `config/{app}/{orgUID}.yaml` —
 * jener Baum ist pro Organisation, verlangt eine `UID` im YAML und wird von
 * `readAllConfigs` ausschließlich unter `config/admin/UUID-<uuid>.yaml` gelesen.
 * Der Vertrag hat seinen eigenen, globalen Schlüssel:
 *
 *     <bucket>/config/contract/backends.yaml
 *
 * Im Repo liegt nur das **Beispiel** (`contract/backends.example.yaml`) als Vorlage.
 *
 * ## Flach machen, was zwei Ebenen hat
 *
 * Der Vertrag beschreibt App- und Zweig-Ebene je als **zwei Töpfe**: `environment`
 * (env.js-Keys) und die App-Felder daneben (`title`, `description`, `icon`, `roles`,
 * `domain`). Gespeichert wird je Ebene **ein flaches Objekt** — beide Töpfe
 * verschmolzen. Der Broker mischt später `{...AppEbene, ...Zweig}`; weil beide
 * Ebenen dieselben Schlüssel tragen können, überschreibt der Zweig je Schlüssel.
 *
 * ## Die Richtung ist einseitig
 *
 * Der Vertrag beschreibt nur das **Angebot** (welche Apps und Zweige es gibt). Was
 * eine Organisation gewählt hat, steht in `OrgAppDeployment` — dieses Modul kennt
 * die Tabelle nicht. Ein Re-Import kann deshalb keine Admin-Entscheidung
 * zurücksetzen. Innerhalb einer aufgeführten App werden entfernte Zweige nur
 * gelöscht, wenn sie keine Organisation mehr wählt; die anderen meldet
 * `saveBackendBranches` als `kept` (Warnung, kein Fehler).
 *
 * `parseContract` / `flattenEntry` sind **rein** — die Standardwege (S3, DB)
 * werden erst in `importContract` geladen. So bleiben Form und Fehlerfälle ohne
 * S3 und ohne Datenbank prüfbar.
 *
 * @see contract/backends.example.yaml — die Form
 * @see PLAN-app-registry.md — das Registry-Modell
 */

import YAML from 'yaml';

const logPrefix = '[contract]';

/** Der globale Vertrags-Schlüssel im Konfig-Bucket. */
export const CONTRACT_KEY = 'config/contract/backends.yaml';

/**
 * Verschmilzt die zwei Töpfe einer Ebene zu **einem** flachen Objekt.
 *
 * `environment` (env.js-Keys) und die App-Felder daneben werden zusammengelegt;
 * `branches` gehört nicht dazu (es ist die nächste Ebene, nicht ein Feld).
 * Reihenfolge ist belanglos, weil sich die beiden Töpfe per Definition nicht
 * überschneiden: `environment` trägt env.js-Keys, der Rest Anzeige/Routing.
 *
 * @param {Record<string, unknown>} entry
 * @param {string} label - für die Fehlermeldung
 * @returns {Record<string, unknown>}
 */
export function flattenEntry(entry, label) {
    const { environment, branches: _branches, ...meta } = entry;
    if (environment !== undefined && (typeof environment !== 'object' || environment === null || Array.isArray(environment))) {
        throw new Error(`"${label}": "environment" is not an object`);
    }
    return { ...(environment ?? {}), ...meta };
}

/**
 * Zerlegt den Vertragstext in `[appKey, { environment, branches }]`-Paare.
 *
 * Geprüft wird **nur die Form** (`branches` da, Objekte). Die Prüfung der
 * Schlüssel und Werte liegt bei `saveAppCatalog` / `saveBackendBranches` — die
 * Instanz, die schreibt. Ein zweiter Prüfer hier wäre eine zweite Wahrheit.
 *
 * @param {string} text
 * @returns {Array<[string, {environment: object, branches: Record<string, object>}]>}
 */
export function parseContract(text) {
    const parsed = YAML.parse(text);
    if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
        throw new Error('Contract is not an object of app entries');
    }

    const apps = [];
    for (const [appKey, entry] of Object.entries(parsed)) {
        if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
            throw new Error(`"${appKey}": entry is not an object`);
        }
        const { branches } = entry;
        if (!branches || typeof branches !== 'object' || Array.isArray(branches)) {
            throw new Error(`"${appKey}": "branches" is missing or not an object`);
        }

        const branchMap = {};
        for (const [branch, branchEntry] of Object.entries(branches)) {
            if (!branchEntry || typeof branchEntry !== 'object' || Array.isArray(branchEntry)) {
                throw new Error(`"${appKey}.${branch}": branch is not an object`);
            }
            branchMap[branch] = flattenEntry(branchEntry, `${appKey}.${branch}`);
        }

        apps.push([appKey, { environment: flattenEntry(entry, appKey), branches: branchMap }]);
    }
    return apps;
}

/**
 * Ein Durchlauf: Vertrag lesen, je App importieren, Ergebnis melden.
 *
 * Die Abhängigkeiten sind injizierbar (`readFile`, `saveAppCatalog`,
 * `saveBackendBranches`) — so ist die Funktion ohne S3 und ohne Datenbank
 * prüfbar, und der Aufrufer entscheidet, ob er den echten Konfig-Bucket nimmt.
 * Ohne Injektion werden die Standardwege erst hier geladen (siehe Dateikopf).
 *
 * Ein leeres/fehlendes App-`environment` überspringt den App-Schritt: ein
 * Vertrag, der alles in die Zweige legt, ist gültig und soll die vorhandene
 * App-Ebene nicht löschen.
 *
 * @param {{
 *   readFile?: (key: string) => Promise<string>,
 *   saveAppCatalog?: (appKey: string, environment: object) => Promise<unknown>,
 *   saveBackendBranches?: (appKey: string, branches: Record<string, object>) => Promise<{upserted?: string[], removed?: string[], kept?: string[]}>,
 *   logPrefix?: string
 * }} [options]
 * @returns {Promise<{ok: boolean, apps: number, branches: number, kept: string[], removed: string[], reason?: string}>}
 */
export async function importContract({
    readFile,
    saveAppCatalog,
    saveBackendBranches,
    logPrefix: prefix = logPrefix,
} = {}) {
    if (!readFile || !saveAppCatalog || !saveBackendBranches) {
        const [{ getConfigFile }, registryService] = await Promise.all([
            import('../../config/configFile.js'),
            import('../orgaSettings/registryService.js'),
        ]);
        readFile = readFile ?? getConfigFile;
        saveAppCatalog = saveAppCatalog ?? registryService.saveAppCatalog;
        saveBackendBranches = saveBackendBranches ?? registryService.saveBackendBranches;
    }

    /** @type {string} */
    let text;
    try {
        text = await readFile(CONTRACT_KEY);
    } catch (error) {
        // Kein Vertrag ist kein Defekt: die Auslieferung läuft mit dem zuletzt
        // gültigen Katalog weiter (§6.9). Der Aufrufer loggt die Ursache.
        return { ok: false, apps: 0, branches: 0, kept: [], removed: [], reason: `unreadable: ${error?.message || error}` };
    }

    // Erst vollständig parsen, dann schreiben: ein Formfehler soll keinen halb
    // importierten Katalog hinterlassen.
    const apps = parseContract(text);

    /** @type {string[]} */
    const kept = [];
    /** @type {string[]} */
    const removed = [];
    let branches = 0;

    for (const [appKey, { environment, branches: branchMap }] of apps) {
        if (environment && Object.keys(environment).length > 0) {
            await saveAppCatalog(appKey, environment);
        }

        const result = await saveBackendBranches(appKey, branchMap);
        branches += result?.upserted?.length ?? 0;
        for (const branch of result?.kept ?? []) kept.push(`${appKey}:${branch}`);
        for (const branch of result?.removed ?? []) removed.push(`${appKey}:${branch}`);
        console.log(
            `${prefix} ${appKey}: ${result?.upserted?.length ?? 0} Zweig(e) geschrieben` +
            (result?.removed?.length ? `, ${result.removed.length} entfernt` : '') +
            (result?.kept?.length ? `, ${result.kept.length} behalten` : ''),
        );
    }

    if (kept.length > 0) {
        // Kein Fehler, aber ein Widerspruch: der Vertrag nennt diese Zweige nicht
        // mehr, eine Organisation wählt sie aber noch. Gelöscht wurde **nicht** —
        // sonst wechselte eine laufende Organisation still ihr Backend.
        console.warn(
            `${prefix} WARNUNG: ${kept.length} Zweig(e) stehen nicht mehr im Vertrag, ` +
            `sind aber noch zugeordnet und wurden NICHT entfernt: ${kept.join(', ')}`,
        );
    }

    return { ok: true, apps: apps.length, branches, kept, removed };
}

/**
 * Import-Durchlauf für den Start / den Reload-Endpunkt, mit Logging und ohne Wurf.
 *
 * Absichtlich **nicht** werfend: ein fehlender oder kaputter Vertrag darf den
 * Serverstart (oder einen Reload-Aufruf) nicht scheitern lassen — die Auslieferung
 * läuft mit dem zuletzt gültigen Katalog weiter.
 *
 * @returns {Promise<Awaited<ReturnType<typeof importContract>>>}
 */
export async function importContractSafely() {
    try {
        const result = await importContract();
        if (!result.ok) {
            console.warn(`${logPrefix} kein Import: ${result.reason}`);
        } else {
            console.log(`${logPrefix} importiert: ${result.apps} App(s), ${result.branches} Zweig(e)`);
        }
        return result;
    } catch (error) {
        console.error(`${logPrefix} Import fehlgeschlagen:`, error?.message || error);
        return { ok: false, apps: 0, branches: 0, kept: [], removed: [], reason: error?.message || String(error) };
    }
}