fix(proxmox): vorübergehende Störungen wiederholen statt abbrechen
Der Feldtest auf einem ausgelasteten pve-demo scheiterte an Verbindungswacklern, nicht an Proxmox: pveproxy meldete „Broken pipe“ (Backend weg), schloss Keep-Alive-Verbindungen, Storage-Abfragen dauerten 15 s. GET wird bis zu dreimal wiederholt, Schreibendes nur bei sicher nicht angekommener Anfrage; Schreibaufrufe dürfen 120 s dauern, Keep-Alive 2 s. Refs #020 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,8 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
### Behoben
|
### 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)
|
- Ü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)
|
- 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)
|
- Beim Wechsel des Servers im Assistenten wurde die erste Bridge statt vmbr0 vorbelegt (#023)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package befehle
|
package befehle
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -189,11 +190,22 @@ func (u *umgebung) batchBefehl() *cobra.Command {
|
|||||||
fehlgeschlagen := 0
|
fehlgeschlagen := 0
|
||||||
|
|
||||||
if trocken {
|
if trocken {
|
||||||
for nr, e := range eintraege {
|
ausgegeben := false
|
||||||
if nr > 0 && !u.opt.json {
|
for _, e := range eintraege {
|
||||||
fmt.Fprintln(u.aus)
|
// 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
|
var ef *exitFehler
|
||||||
if !errors.As(err, &ef) || !ef.gemeldet {
|
if !errors.As(err, &ef) || !ef.gemeldet {
|
||||||
u.fehlerMelden(err)
|
u.fehlerMelden(err)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export type Aufruf = { methode: Methode; pfad: string; parameter?: Parameter };
|
|||||||
|
|
||||||
export type AufrufBeobachter = (
|
export type AufrufBeobachter = (
|
||||||
aufruf: Aufruf,
|
aufruf: Aufruf,
|
||||||
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler },
|
ergebnis: { dauerMs: number; fehler?: ProxmoxFehler; wiederholung?: number },
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -38,6 +38,41 @@ export function istTrockenUpid(upid: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STANDARD_ZEITLIMIT_MS = 30_000;
|
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. */
|
/** Formular-Kodierung wie pvesh: Wahrheitswerte als 1/0, Listen als wiederholte Schlüssel. */
|
||||||
export function parameterKodieren(parameter: Parameter | undefined): URLSearchParams {
|
export function parameterKodieren(parameter: Parameter | undefined): URLSearchParams {
|
||||||
@@ -75,6 +110,8 @@ export class ProxmoxClient {
|
|||||||
zeitlimitMs?: number;
|
zeitlimitMs?: number;
|
||||||
beobachter?: AufrufBeobachter;
|
beobachter?: AufrufBeobachter;
|
||||||
trocken?: boolean;
|
trocken?: boolean;
|
||||||
|
/** Pause vor dem n-ten Wiederholen (Tests setzen 0). */
|
||||||
|
pauseMs?: (versuch: number) => number;
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
|
if (!TOKEN_ID_MUSTER.test(verbindung.tokenId)) {
|
||||||
@@ -85,7 +122,7 @@ export class ProxmoxClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.basis = verbindung.url.replace(/\/+$/, "") + "/api2/json";
|
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}`;
|
this.kopf = `PVEAPIToken=${verbindung.tokenId}=${verbindung.tokenGeheimnis}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,15 +157,30 @@ export class ProxmoxClient {
|
|||||||
return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T;
|
return `${TROCKEN_UPID_PRAEFIX}${knoten}:${this.geplant.length}` as T;
|
||||||
}
|
}
|
||||||
const beginn = Date.now();
|
const beginn = Date.now();
|
||||||
try {
|
for (let versuch = 1; ; versuch += 1) {
|
||||||
const ergebnis = await this.senden<T>(aufruf);
|
try {
|
||||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn });
|
const ergebnis = await this.senden<T>(aufruf);
|
||||||
return ergebnis;
|
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn });
|
||||||
} catch (fehler) {
|
return ergebnis;
|
||||||
const proxmoxFehler =
|
} catch (fehler) {
|
||||||
fehler instanceof ProxmoxFehler ? fehler : verbindungsFehler(fehler, this.verbindung.url);
|
// GET darf immer noch einmal; Schreibendes nur, wenn die Anfrage sicher nie ankam.
|
||||||
this.optionen.beobachter?.(aufruf, { dauerMs: Date.now() - beginn, fehler: proxmoxFehler });
|
const art = stoerung(fehler);
|
||||||
throw proxmoxFehler;
|
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" } : {}),
|
...(mitRumpf ? { "content-type": "application/x-www-form-urlencoded" } : {}),
|
||||||
},
|
},
|
||||||
body: mitRumpf ? kodiert.toString() : undefined,
|
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();
|
const text = await antwort.text();
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ export function aufrufLogger(senke: EreignisSenke): AufrufBeobachter {
|
|||||||
if (!ergebnis.fehler && aufruf.methode === "GET" && SCHLEIFE.test(pfad)) return;
|
if (!ergebnis.fehler && aufruf.methode === "GET" && SCHLEIFE.test(pfad)) return;
|
||||||
void senke.log(
|
void senke.log(
|
||||||
ergebnis.fehler ? "warn" : "debug",
|
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` : ""
|
||||||
|
}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,15 +8,22 @@ import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-moc
|
|||||||
|
|
||||||
const mock = new ProxmoxMock();
|
const mock = new ProxmoxMock();
|
||||||
|
|
||||||
function client(ueberschreiben: Partial<ConstructorParameters<typeof ProxmoxClient>[0]> = {}) {
|
function client(
|
||||||
return new ProxmoxClient({
|
ueberschreiben: Partial<ConstructorParameters<typeof ProxmoxClient>[0]> = {},
|
||||||
url: mock.url,
|
optionen: ConstructorParameters<typeof ProxmoxClient>[1] = {},
|
||||||
tokenId: MOCK_TOKEN_ID,
|
) {
|
||||||
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
return new ProxmoxClient(
|
||||||
tlsPruefen: false,
|
{
|
||||||
fingerabdruck: mock.fingerabdruck,
|
url: mock.url,
|
||||||
...ueberschreiben,
|
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<unknown>): Promise<ProxmoxFehler> {
|
async function fehlerVon(versprechen: Promise<unknown>): Promise<ProxmoxFehler> {
|
||||||
@@ -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)", () => {
|
describe("Proxmox-Tasks (UPID)", () => {
|
||||||
const UPID = "UPID:pve01:0000A1B2:00C3D4E5:6710F00D:qmclone:9013:proxdeploy@pve!test:";
|
const UPID = "UPID:pve01:0000A1B2:00C3D4E5:6710F00D:qmclone:9013:proxdeploy@pve!test:";
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type MockAntwort = {
|
|||||||
statusText?: string;
|
statusText?: string;
|
||||||
data?: unknown;
|
data?: unknown;
|
||||||
errors?: Record<string, string>;
|
errors?: Record<string, string>;
|
||||||
|
/** Verbindung ohne Antwort hart trennen – wie ein pveproxy, der eine Keep-Alive-Verbindung schließt. */
|
||||||
|
trennen?: boolean;
|
||||||
};
|
};
|
||||||
export type MockRoute = (
|
export type MockRoute = (
|
||||||
anfrage: MockAnfrage,
|
anfrage: MockAnfrage,
|
||||||
@@ -124,6 +126,10 @@ export class ProxmoxMock {
|
|||||||
if (!treffer) continue;
|
if (!treffer) continue;
|
||||||
try {
|
try {
|
||||||
const antwort = await route(anfrage, treffer);
|
const antwort = await route(anfrage, treffer);
|
||||||
|
if (antwort.trennen) {
|
||||||
|
res.socket?.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
senden(antwort.status ?? 200, antwort.statusText ?? "OK", {
|
senden(antwort.status ?? 200, antwort.statusText ?? "OK", {
|
||||||
data: antwort.data ?? null,
|
data: antwort.data ?? null,
|
||||||
...(antwort.errors ? { errors: antwort.errors } : {}),
|
...(antwort.errors ? { errors: antwort.errors } : {}),
|
||||||
|
|||||||
Reference in New Issue
Block a user