diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fc52f6..bfc5671 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Geändert ### Behoben +- Auf ausgelasteten Nodes brachen Aufträge beim ersten Verbindungswackler ab („Broken pipe“, „other side closed“, Zeitüberschreitung). Lesende Proxmox-Aufrufe werden jetzt bis zu dreimal wiederholt, schreibende nur, wenn die Anfrage sicher nicht ankam; Schreibaufrufe dürfen 120 s dauern; Wiederholungen stehen im Detail-Log (#020) +- `proxdeploy batch --dry-run --quiet` gab Leerzeilen für erfolgreiche Einträge aus (#020) - Übersicht am Handy breiter als der Bildschirm (lange Auftragszeilen); der Browser zoomte heraus und die Navigation verdeckte „Abmelden“ (#023) - Seitenleiste scrollt, wenn ihr Inhalt höher als der Bildschirm ist (#023) - Beim Wechsel des Servers im Assistenten wurde die erste Bridge statt vmbr0 vorbelegt (#023) diff --git a/cli/internal/befehle/batch.go b/cli/internal/befehle/batch.go index acf8020..e5c8421 100644 --- a/cli/internal/befehle/batch.go +++ b/cli/internal/befehle/batch.go @@ -1,6 +1,7 @@ package befehle import ( + "bytes" "errors" "fmt" "os" @@ -189,11 +190,22 @@ func (u *umgebung) batchBefehl() *cobra.Command { fehlgeschlagen := 0 if trocken { - for nr, e := range eintraege { - if nr > 0 && !u.opt.json { - fmt.Fprintln(u.aus) + ausgegeben := false + for _, e := range eintraege { + // Leerzeile nur zwischen Blöcken, die auch etwas ausgeben (mit --quiet bleiben gute stumm). + puffer := &bytes.Buffer{} + ziel := u.aus + u.aus = puffer + err := u.trockenlauf(ctx, c, e.anfrage, e.name) + u.aus = ziel + if puffer.Len() > 0 { + if ausgegeben && !u.opt.json { + fmt.Fprintln(u.aus) + } + _, _ = u.aus.Write(puffer.Bytes()) + ausgegeben = true } - if err := u.trockenlauf(ctx, c, e.anfrage, e.name); err != nil { + if err != nil { var ef *exitFehler if !errors.As(err, &ef) || !ef.gemeldet { u.fehlerMelden(err) diff --git a/src/engine/proxmox/client.ts b/src/engine/proxmox/client.ts index bfe67df..e27b68a 100644 --- a/src/engine/proxmox/client.ts +++ b/src/engine/proxmox/client.ts @@ -23,7 +23,7 @@ export type Aufruf = { methode: Methode; pfad: string; parameter?: Parameter }; export type AufrufBeobachter = ( aufruf: Aufruf, - ergebnis: { dauerMs: number; fehler?: ProxmoxFehler }, + ergebnis: { dauerMs: number; fehler?: ProxmoxFehler; wiederholung?: number }, ) => void; /** @@ -38,6 +38,41 @@ export function istTrockenUpid(upid: unknown): boolean { } const STANDARD_ZEITLIMIT_MS = 30_000; +/** + * Schreibende Aufrufe dürfen länger dauern: ein zu früh abgebrochenes „VM anlegen“ läuft auf dem + * Node womöglich trotzdem – ein zweiter Versuch wäre dann doppelt. + */ +const SCHREIB_ZEITLIMIT_MS = 120_000; +/** Versuche je Aufruf bei vorübergehenden Störungen (nur, wo ein Wiederholen sicher ist). */ +const VERSUCHE = 3; + +/** + * Ob eine Störung vorübergehend ist – und ob die Anfrage den Node womöglich erreicht hat. + * Ausgelastete Nodes liefern davon reichlich: pveproxy verliert die Verbindung zu pvedaemon + * („Broken pipe“, 595/596), schließt Keep-Alive-Verbindungen, Abfragen laufen ins Zeitlimit. + */ +export function stoerung(fehler: unknown): "nicht_angekommen" | "unklar" | null { + if (fehler instanceof ProxmoxFehler) { + const status = fehler.details.status; + if (status === 595 || status === 596) return "unklar"; + if ( + (status === 500 || status === 502 || status === 503) && + /broken pipe|connection (refused|reset|closed)|got timeout|timed out/i.test(fehler.ursache) + ) + return "unklar"; + return null; + } + let ursache: unknown = fehler; + for (let i = 0; i < 3 && ursache instanceof Error && ursache.cause; i += 1) ursache = ursache.cause; + const code = (ursache as { code?: string } | undefined)?.code ?? ""; + if (code === "ECONNREFUSED") return "nicht_angekommen"; + if ( + ["ECONNRESET", "EPIPE", "UND_ERR_SOCKET", "UND_ERR_CONNECT_TIMEOUT", "ETIMEDOUT"].includes(code) || + (ursache instanceof Error && (ursache.name === "TimeoutError" || ursache.name === "AbortError")) + ) + return "unklar"; + return null; +} /** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */ export function parameterKodieren(parameter: Parameter | undefined): URLSearchParams { @@ -75,6 +110,8 @@ export class ProxmoxClient { zeitlimitMs?: number; beobachter?: AufrufBeobachter; trocken?: boolean; + /** Pause vor dem n-ten Wiederholen (Tests setzen 0). */ + pauseMs?: (versuch: number) => number; } = {}, ) { if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) { @@ -85,7 +122,7 @@ export class ProxmoxClient { ); } this.basis = verbindung.url.replace(/\/+$/, "") + "/api2/json"; - this.agent = new Agent({ connect: tlsConnector(verbindung), keepAliveTimeout: 10_000 }); + this.agent = new Agent({ connect: tlsConnector(verbindung), keepAliveTimeout: 2_000 }); this.kopf = `PVEAPIToken=${verbindung.tokenId}=${verbindung.tokenGeheimnis}`; } @@ -120,15 +157,30 @@ export class ProxmoxClient { return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T; } const beginn = Date.now(); - try { - const ergebnis = await this.senden(aufruf); - this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn }); - return ergebnis; - } catch (fehler) { - const proxmoxFehler = - fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url); - this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn, fehler: proxmoxFehler }); - throw proxmoxFehler; + for (let versuch = 1; ; versuch += 1) { + try { + const ergebnis = await this.senden(aufruf); + this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn }); + return ergebnis; + } catch (fehler) { + // GET darf immer noch einmal; Schreibendes nur, wenn die Anfrage sicher nie ankam. + const art = stoerung(fehler); + const sicher = art === "nicht_angekommen" || (art === "unklar" && aufruf.methode === "GET"); + if (sicher && versuch < VERSUCHE) { + const pause = this.optionen.pauseMs?.(versuch) ?? (versuch === 1 ? 1000 : 3000); + this.optionen.beobachter?.(aufruf, { + dauerMs: Date.now() - beginn, + fehler: fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url), + wiederholung: versuch, + }); + await new Promise((fertig) => setTimeout(fertig, pause)); + continue; + } + const proxmoxFehler = + fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url); + this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn, fehler: proxmoxFehler }); + throw proxmoxFehler; + } } } @@ -146,7 +198,10 @@ export class ProxmoxClient { ...(mitRumpf ? { "content-type": "application/x-www-form-urlencoded" } : {}), }, body: mitRumpf ? kodiert.toString() : undefined, - signal: AbortSignal.timeout(this.optionen.zeitlimitMs ?? STANDARD_ZEITLIMIT_MS), + signal: AbortSignal.timeout( + this.optionen.zeitlimitMs ?? + (aufruf.methode === "GET" ? STANDARD_ZEITLIMIT_MS : SCHREIB_ZEITLIMIT_MS), + ), }); const text = await antwort.text(); diff --git a/src/worker/aufruf-log.ts b/src/worker/aufruf-log.ts index 06e4039..81b3e4d 100644 --- a/src/worker/aufruf-log.ts +++ b/src/worker/aufruf-log.ts @@ -14,7 +14,9 @@ export function aufrufLogger(senke: EreignisSenke): AufrufBeobachter { if (!ergebnis.fehler && aufruf.methode === "GET" && SCHLEIFE.test(pfad)) return; void senke.log( ergebnis.fehler ? "warn" : "debug", - `${aufruf.methode} ${pfad} · ${ergebnis.dauerMs} ms${ergebnis.fehler ? ` · ${ergebnis.fehler.ursache}` : ""}`, + `${aufruf.methode} ${pfad} · ${ergebnis.dauerMs} ms${ergebnis.fehler ? ` · ${ergebnis.fehler.ursache}` : ""}${ + ergebnis.wiederholung ? ` · vorübergehend, Versuch ${ergebnis.wiederholung + 1} folgt` : "" + }`, ); }; } diff --git a/tests/engine/proxmox-client.test.ts b/tests/engine/proxmox-client.test.ts index da9df01..d75213f 100644 --- a/tests/engine/proxmox-client.test.ts +++ b/tests/engine/proxmox-client.test.ts @@ -8,15 +8,22 @@ import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-moc const mock = new ProxmoxMock(); -function client(ueberschreiben: Partial[0]> = {}) { - return new ProxmoxClient({ - url: mock.url, - tokenId: MOCK_TOKEN_ID, - tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS, - tlsPruefen: false, - fingerabdruck: mock.fingerabdruck, - ...ueberschreiben, - }); +function client( + ueberschreiben: Partial[0]> = {}, + optionen: ConstructorParameters[1] = {}, +) { + return new ProxmoxClient( + { + url: mock.url, + tokenId: MOCK_TOKEN_ID, + tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS, + tlsPruefen: false, + fingerabdruck: mock.fingerabdruck, + ...ueberschreiben, + }, + // Ohne Pausen: Wiederholungen sollen Tests nicht ausbremsen. + { pauseMs: () => 0, ...optionen }, + ); } async function fehlerVon(versprechen: Promise): Promise { @@ -174,6 +181,58 @@ describe("ProxmoxClient", () => { }); }); +describe("Vorübergehende Störungen", () => { + it("wiederholt GET nach „Broken pipe“ (596) und getrennter Verbindung und meldet es dem Beobachter", async () => { + let n = 0; + mock.route("GET", "/nodes/pve01/storage", () => { + n += 1; + if (n === 1) return { status: 596, statusText: "Broken pipe" }; + if (n === 2) return { trennen: true }; + return { data: [{ storage: "local" }] }; + }); + const gesehen: Array<{ fehler?: string; wiederholung?: number }> = []; + const c = client( + {}, + { beobachter: (_a, e) => gesehen.push({ fehler: e.fehler?.ursache, wiederholung: e.wiederholung }) }, + ); + expect(await c.get("/nodes/pve01/storage")).toEqual([{ storage: "local" }]); + expect(n).toBe(3); + expect(gesehen.map((g) => g.wiederholung)).toEqual([1, 2, undefined]); + expect(gesehen[0]!.fehler).toBe("Broken pipe"); + }); + + it("gibt nach drei Versuchen auf", async () => { + let n = 0; + mock.route("GET", "/nodes/pve02/storage", () => { + n += 1; + return { status: 596, statusText: "Broken pipe" }; + }); + const fehler = await fehlerVon(client().get("/nodes/pve02/storage")); + expect(fehler.ursache).toBe("Broken pipe"); + expect(n).toBe(3); + }); + + it("wiederholt Schreibendes nicht, wenn die Anfrage den Node erreicht haben kann", async () => { + let n = 0; + mock.route("POST", "/nodes/pve01/qemu", () => { + n += 1; + return n === 1 ? { trennen: true } : { data: "UPID:pve01:1:2:3:qmcreate:100:x:" }; + }); + await expect(client().post("/nodes/pve01/qemu", { vmid: 100 })).rejects.toThrow(); + expect(n).toBe(1); + }); + + it("wiederholt keine fachlichen Fehler (500 mit Proxmox-Meldung, 403, 501)", async () => { + let n = 0; + mock.route("GET", "/nodes/pve03/storage", () => { + n += 1; + return { status: 500, statusText: "storage 'x' does not exist" }; + }); + await fehlerVon(client().get("/nodes/pve03/storage")); + expect(n).toBe(1); + }); +}); + describe("Proxmox-Tasks (UPID)", () => { const UPID = "UPID:pve01:0000A1B2:00C3D4E5:6710F00D:qmclone:9013:proxdeploy@pve!test:"; diff --git a/tests/proxmox-mock/mock.ts b/tests/proxmox-mock/mock.ts index 9fbac1b..4daec3e 100644 --- a/tests/proxmox-mock/mock.ts +++ b/tests/proxmox-mock/mock.ts @@ -26,6 +26,8 @@ export type MockAntwort = { statusText?: string; data?: unknown; errors?: Record; + /** Verbindung ohne Antwort hart trennen – wie ein pveproxy, der eine Keep-Alive-Verbindung schließt. */ + trennen?: boolean; }; export type MockRoute = ( anfrage: MockAnfrage, @@ -124,6 +126,10 @@ export class ProxmoxMock { if (!treffer) continue; try { const antwort = await route(anfrage, treffer); + if (antwort.trennen) { + res.socket?.destroy(); + return; + } senden(antwort.status ?? 200, antwort.statusText ?? "OK", { data: antwort.data ?? null, ...(antwort.errors ? { errors: antwort.errors } : {}),