// @ts-check
/**
* Release-Import — die Versionen aus dem Artefakt-Bucket.
*
* Liest den S3-Lageplan `<produkt>/<version>/…` aus dem Artefakt-Bucket
* (`commtool-apps`, siehe `ARTIFACT_BUCKET`) und schreibt je gefundener Version
* eine Zeile nach `AppRelease`. Damit steht die Liste der wählbaren Versionen
* **ohne** einen zweiten Pflegeort: was im Bucket liegt, ist wählbar.
*
* ## Warum der Schlüssel das Produkt ist
*
* Der Lageplan ist **produktweit**: `member/5.30.1/` liegt genau einmal und
* bedient `member.app`, `member.test`, `member.dev`. Deshalb ist
* `AppRelease.AppKey` das **Produkt** (`member`) — nicht die App-ID. Die
* Umgebung sitzt in der Zuordnung des Admins (`OrgAppDeployment`), nicht im
* Ablageort. Dieselbe Naht wie beim Vertrag (`productOfAppId`).
*
* ## Kanäle entstehen hier nicht
*
* `latest` und `stable` sind **Auflösungsregeln**, keine Zeilen (siehe
* `registryService.resolveRelease`): fehlt eine Zeile mit dem Namen, greift die
* neueste Version. Der Import legt deshalb nur **konkrete** Versionszeilen an
* (`5.30.1`). Eine Kanal-Zeile, die der Release-Job setzt (Promotion), bleibt
* unangetastet — das Pruning löscht nur Zeilen, die wie eine Version aussehen.
*
* ## Die Richtung ist einseitig
*
* Der Bucket beschreibt nur, **was es gibt**. Was eine Organisation gewählt hat,
* steht in `OrgAppDeployment` — dieses Modul kennt die Tabelle nicht und kann
* eine Admin-Entscheidung nicht zurücksetzen.
*
* ## Ohne Berechtigung ist es kein Defekt
*
* Das App-Konto darf den Artefakt-Bucket in der Regel **nicht** lesen (er gehört
* dem Broker). Der Import meldet das als Warnung und lässt die vorhandenen
* Zeilen stehen — die Auslieferung läuft mit dem letzten Stand weiter.
*
* `parseArtifactKeys` ist **rein** — die Standardwege (S3, DB) werden erst in
* `importReleases` geladen. So bleiben Form und Fehlerfälle ohne Bucket und ohne
* Datenbank prüfbar.
*
* @see src/Router/registry/contractImport.js — dasselbe Muster für den Vertrag
* @see app-broker/PLAN.md §6.9 — der S3-Lageplan und die Kanäle
*/
const logPrefix = '[releases]';
/**
* Zerlegt Objekt-Schlüssel in `{ produkt: [version, …] }`.
*
* Erwartet den Lageplan `<produkt>/<version>/<datei…>`. Nur **konkrete**
* Versionsordner zählen: das zweite Segment muss mit einer Ziffer beginnen
* (`5.30.1`). Damit fallen Hilfsordner (`languages/…`) und ein flacher Müllpfad
* heraus, ohne eine Liste zu pflegen.
*
* @param {Iterable<string>} keys
* @returns {Record<string, string[]>} Produkt → sortierte, eindeutige Versionen
*/
export function parseArtifactKeys(keys) {
/** @type {Map<string, Set<string>>} */
const byProduct = new Map();
for (const key of keys ?? []) {
if (typeof key !== 'string') continue;
const parts = key.split('/').filter((segment) => segment.length > 0);
if (parts.length < 3) continue;
const [product, version] = parts;
if (!product || !version || !/^\d/.test(version)) continue;
if (!byProduct.has(product)) byProduct.set(product, new Set());
byProduct.get(product).add(version);
}
/** @type {Record<string, string[]>} */
const result = {};
for (const [product, versions] of byProduct) {
result[product] = [...versions].sort();
}
return result;
}
/** Der S3-Präfix eines Releases — abgeleitet, nicht eingetippt (§6.9). */
export const releasePrefix = (product, version) => `${product}/${version}/`;
/**
* Listet die Objekt-Schlüssel des Artefakt-Buckets über den S3-Client.
*
* Rekursiv: das Ergebnis ist die flache Schlüsselliste, aus der
* {@link parseArtifactKeys} den Lageplan ableitet. Ein zweiter Weg (Ordner für
* Ordner) wäre mehr Aufrufe für dasselbe Ergebnis — der Bucket ist klein.
*
* @returns {Promise<string[]>}
*/
export async function listArtifactKeys() {
const { artifactMinioClient, ARTIFACT_BUCKET } = await import('../../utils/s3Client.js');
if (!artifactMinioClient) throw new Error('S3 is not initialised (initS3)');
/** @type {string[]} */
const keys = [];
await new Promise((resolve, reject) => {
const stream = artifactMinioClient.listObjectsV2(ARTIFACT_BUCKET, '', true);
stream.on('data', (obj) => keys.push(obj.name));
stream.on('end', () => resolve(undefined));
stream.on('error', reject);
});
return keys;
}
/**
* Ein Durchlauf: Artefakte listen, Versionen schreiben, verschwundene entfernen.
*
* Die Abhängigkeiten sind injizierbar (`listKeys`, `listKnown`, `saveRelease`,
* `deleteRelease`) — so ist die Funktion ohne Bucket und ohne Datenbank prüfbar.
*
* Pruning: für **gescannte** Produkte werden Zeilen entfernt, die wie eine
* Version aussehen (`/^\d/`) und nicht mehr gefunden wurden. Kanal-Zeilen
* (`latest`, `stable`) und die Produkte, die der Scan **nicht** kennt, bleiben
* stehen — ein abgebrochener Scan darf nichts löschen.
*
* @param {{
* listKeys?: () => Promise<string[]>,
* listKnown?: () => Promise<Array<{AppKey: string, Version: string}>>,
* saveRelease?: (release: {appKey: string, version: string, prefix: string}) => Promise<unknown>,
* deleteRelease?: (appKey: string, version: string) => Promise<unknown>,
* logPrefix?: string
* }} [options]
* @returns {Promise<{ok: boolean, products: number, upserted: number, removed: number, reason?: string}>}
*/
export async function importReleases({
listKeys,
listKnown,
saveRelease,
deleteRelease,
logPrefix: prefix = logPrefix,
} = {}) {
if (!listKeys || !listKnown || !saveRelease || !deleteRelease) {
const registryService = await import('../orgaSettings/registryService.js');
listKeys = listKeys ?? (() => listArtifactKeys());
listKnown = listKnown ?? registryService.listReleaseRows;
saveRelease = saveRelease ?? registryService.saveAppRelease;
deleteRelease = deleteRelease ?? registryService.deleteAppRelease;
}
/** @type {string[]} */
let keys;
try {
keys = await listKeys();
} catch (error) {
// Kein Zugang ist kein Defekt: die vorhandenen Zeilen bleiben stehen,
// die Auslieferung läuft mit dem letzten Stand weiter.
return { ok: false, products: 0, upserted: 0, removed: 0, reason: `unreadable: ${error?.message || error}` };
}
const discovered = parseArtifactKeys(keys);
const products = Object.keys(discovered);
const known = await listKnown();
let upserted = 0;
let removed = 0;
for (const product of products) {
const versions = discovered[product];
for (const version of versions) {
await saveRelease({ appKey: product, version, prefix: releasePrefix(product, version) });
upserted += 1;
}
const gone = known.filter(
(row) => row.AppKey === product && /^\d/.test(row.Version) && !versions.includes(row.Version),
);
for (const row of gone) {
await deleteRelease(product, row.Version);
removed += 1;
}
}
return { ok: true, products: products.length, upserted, removed };
}
/**
* Import-Durchlauf für den Start / den Reload-Endpunkt, mit Logging und ohne Wurf.
*
* Absichtlich **nicht** werfend: ein fehlender Zugang zum Artefakt-Bucket darf
* den Serverstart (oder einen Reload) nicht scheitern lassen.
*
* @returns {Promise<Awaited<ReturnType<typeof importReleases>>>}
*/
export async function importReleasesSafely() {
try {
const result = await importReleases();
if (!result.ok) {
console.warn(`${logPrefix} kein Import: ${result.reason}`);
} else {
console.log(
`${logPrefix} importiert: ${result.upserted} Version(en) in ${result.products} Produkt(en)` +
(result.removed ? `, ${result.removed} entfernt` : ''),
);
}
return result;
} catch (error) {
console.error(`${logPrefix} Import fehlgeschlagen:`, error?.message || error);
return { ok: false, products: 0, upserted: 0, removed: 0, reason: error?.message || String(error) };
}
}