Ohne SSH zum Node kommt der Guest-Agent jetzt über eine feste Bau-Adresse ins Template: cloud-init setzt Adresse und den Bereitstellungsschlüssel für den Standardbenutzer, ProxDeploy meldet sich per SSH in der VM an, installiert den Agent und entfernt danach Schlüssel, Adresse und cloud-init-Angaben. Dabei korrigiert: der Verbindungstest meldete „Template-Bau per API“ auch ohne Weg, den Agent einzubauen. Im Protokoll erscheinen unveränderte JSON-Felder nicht mehr als geändert. Refs #021 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
9.9 KiB
TypeScript
321 lines
9.9 KiB
TypeScript
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
|
||
import { pfad, type ProxmoxClient } from "./client";
|
||
import {
|
||
faehigkeitenAbleiten,
|
||
pfadFuer,
|
||
rechtePruefen,
|
||
versionLesen,
|
||
type Faehigkeiten,
|
||
type FehlendesRecht,
|
||
type PveVersion,
|
||
type RechteJePfad,
|
||
type SshZustand,
|
||
type StorageInfo,
|
||
} from "./faehigkeiten";
|
||
import { ProxmoxFehler } from "./fehler";
|
||
|
||
/**
|
||
* Der Verbindungstest: liest alles, was ProxDeploy über einen Server wissen muss, und leitet die
|
||
* Fähigkeiten ab. Nur lesende Aufrufe. Fehlt für einen Teil ein Recht, wird das als Warnung
|
||
* vermerkt statt den ganzen Test abzubrechen – der Bericht soll gerade dann helfen.
|
||
*/
|
||
export type BridgeInfo = {
|
||
name: string;
|
||
typ: string;
|
||
vlanBewusst: boolean;
|
||
aktiv: boolean;
|
||
cidr: string | null;
|
||
kommentar: string | null;
|
||
};
|
||
|
||
export type SshKnoten = {
|
||
zustand: SshZustand;
|
||
host: string;
|
||
hostSchluessel: string | null;
|
||
bestaetigt: boolean;
|
||
meldung: string | null;
|
||
};
|
||
|
||
export type KnotenInfo = {
|
||
name: string;
|
||
online: boolean;
|
||
ip: string | null;
|
||
version: string | null;
|
||
cpuAuslastung: number | null;
|
||
cpus: number | null;
|
||
speicherBelegt: number | null;
|
||
speicherGesamt: number | null;
|
||
storages: StorageInfo[];
|
||
bridges: BridgeInfo[];
|
||
ssh: SshKnoten | null;
|
||
};
|
||
|
||
export type VnetInfo = { name: string; zone: string; tag: number | null; alias: string | null };
|
||
|
||
export type VerbindungsBericht = {
|
||
geprueftAm: string;
|
||
version: string;
|
||
release: string;
|
||
cluster: { name: string; quorate: boolean } | null;
|
||
knoten: KnotenInfo[];
|
||
vnets: VnetInfo[] | null;
|
||
fehlendeRechte: FehlendesRecht[];
|
||
ssh: SshZustand;
|
||
faehigkeiten: Faehigkeiten;
|
||
warnungen: string[];
|
||
};
|
||
|
||
export type SshZugang = {
|
||
benutzer: string;
|
||
port: number;
|
||
privatSchluessel: string | null;
|
||
hostSchluessel: Record<string, string>;
|
||
};
|
||
|
||
type ClusterStatusEintrag =
|
||
| { type: "cluster"; name: string; quorate: number; nodes: number }
|
||
| { type: "node"; name: string; online: number; ip?: string; local?: number };
|
||
|
||
type NodeEintrag = {
|
||
node: string;
|
||
status: string;
|
||
cpu?: number;
|
||
maxcpu?: number;
|
||
mem?: number;
|
||
maxmem?: number;
|
||
};
|
||
|
||
type StorageEintrag = {
|
||
storage: string;
|
||
type: string;
|
||
content: string;
|
||
active?: number;
|
||
enabled?: number;
|
||
shared?: number;
|
||
avail?: number;
|
||
total?: number;
|
||
};
|
||
|
||
type NetzEintrag = {
|
||
iface: string;
|
||
type: string;
|
||
active?: number;
|
||
bridge_vlan_aware?: number;
|
||
cidr?: string;
|
||
comments?: string;
|
||
};
|
||
|
||
async function versuchen<T>(warnungen: string[], text: string, aufruf: () => Promise<T>): Promise<T | null> {
|
||
try {
|
||
return await aufruf();
|
||
} catch (fehler) {
|
||
if (fehler instanceof ProxmoxFehler && (fehler.art === "recht" || fehler.art === "nicht_gefunden")) {
|
||
warnungen.push(`${text}: ${fehler.ursache}`);
|
||
return null;
|
||
}
|
||
throw fehler;
|
||
}
|
||
}
|
||
|
||
export async function erkunden(
|
||
client: ProxmoxClient,
|
||
optionen: { serverHost: string; ssh: SshZugang | null; bauNetz?: boolean; jetzt?: () => Date },
|
||
): Promise<VerbindungsBericht> {
|
||
const warnungen: string[] = [];
|
||
const version = await client.get<{ version: string; release: string }>("/version");
|
||
const pveVersion: PveVersion = versionLesen(version.version) ?? {
|
||
major: 0,
|
||
minor: 0,
|
||
patch: 0,
|
||
text: version.version,
|
||
};
|
||
if (pveVersion.major < 8) {
|
||
warnungen.push(`PVE ${version.version} wird nicht unterstützt – ProxDeploy braucht 8.0 oder neuer.`);
|
||
}
|
||
|
||
// Cluster und Nodes. /cluster/status braucht Sys.Audit; ohne bleibt die Node-Liste.
|
||
const clusterStatus = await versuchen(warnungen, "Cluster-Status", () =>
|
||
client.get<ClusterStatusEintrag[]>("/cluster/status"),
|
||
);
|
||
const clusterEintrag = clusterStatus?.find((e) => e.type === "cluster");
|
||
const nodeIps = new Map(
|
||
(clusterStatus ?? [])
|
||
.filter((e): e is Extract<ClusterStatusEintrag, { type: "node" }> => e.type === "node")
|
||
.map((e) => [e.name, e.ip ?? null]),
|
||
);
|
||
const nodes = await client.get<NodeEintrag[]>("/nodes");
|
||
|
||
const knoten: KnotenInfo[] = [];
|
||
for (const node of [...nodes].sort((a, b) => a.node.localeCompare(b.node))) {
|
||
const online = node.status === "online";
|
||
let storages: StorageInfo[] = [];
|
||
let bridges: BridgeInfo[] = [];
|
||
let nodeVersion: string | null = null;
|
||
if (online) {
|
||
const v = await versuchen(warnungen, `Version von ${node.node}`, () =>
|
||
client.get<{ version: string }>(pfad`/nodes/${node.node}/version`),
|
||
);
|
||
nodeVersion = v?.version ?? null;
|
||
storages = (
|
||
(await versuchen(warnungen, `Storages von ${node.node}`, () =>
|
||
client.get<StorageEintrag[]>(pfad`/nodes/${node.node}/storage`),
|
||
)) ?? []
|
||
)
|
||
.filter((s) => s.enabled !== 0)
|
||
.map((s) => ({
|
||
storage: s.storage,
|
||
typ: s.type,
|
||
inhalte: s.content.split(",").filter(Boolean),
|
||
aktiv: s.active === 1,
|
||
geteilt: s.shared === 1,
|
||
frei: s.avail ?? null,
|
||
gesamt: s.total ?? null,
|
||
}))
|
||
.sort((a, b) => a.storage.localeCompare(b.storage));
|
||
bridges = (
|
||
(await versuchen(warnungen, `Netzwerk von ${node.node}`, () =>
|
||
client.get<NetzEintrag[]>(pfad`/nodes/${node.node}/network`),
|
||
)) ?? []
|
||
)
|
||
.filter((n) => n.type === "bridge" || n.type === "OVSBridge")
|
||
.map((n) => ({
|
||
name: n.iface,
|
||
typ: n.type,
|
||
vlanBewusst: n.bridge_vlan_aware === 1 || n.type === "OVSBridge",
|
||
aktiv: n.active === 1,
|
||
cidr: n.cidr ?? null,
|
||
kommentar: n.comments?.trim() || null,
|
||
}))
|
||
.sort((a, b) => a.name.localeCompare(b.name));
|
||
}
|
||
knoten.push({
|
||
name: node.node,
|
||
online,
|
||
ip: nodeIps.get(node.node) ?? null,
|
||
version: nodeVersion,
|
||
cpuAuslastung: node.cpu ?? null,
|
||
cpus: node.maxcpu ?? null,
|
||
speicherBelegt: node.mem ?? null,
|
||
speicherGesamt: node.maxmem ?? null,
|
||
storages,
|
||
bridges,
|
||
ssh: null,
|
||
});
|
||
}
|
||
|
||
const vnetsRoh = await versuchen(warnungen, "SDN-VNets", () =>
|
||
client.get<Array<{ vnet: string; zone: string; tag?: number; alias?: string }>>("/cluster/sdn/vnets"),
|
||
);
|
||
const vnets =
|
||
vnetsRoh?.map((v) => ({ name: v.vnet, zone: v.zone, tag: v.tag ?? null, alias: v.alias ?? null })) ??
|
||
null;
|
||
|
||
// Rechte: effektiv je Pfad. Storage-Rechte nur für Storages, die Gäste aufnehmen können.
|
||
const gastStorages = [
|
||
...new Set(
|
||
knoten.flatMap((k) =>
|
||
k.storages
|
||
.filter((s) => s.inhalte.includes("images") || s.inhalte.includes("rootdir"))
|
||
.map((s) => s.storage),
|
||
),
|
||
),
|
||
];
|
||
const onlineNodes = knoten.filter((k) => k.online).map((k) => k.name);
|
||
const pfade = [
|
||
"/",
|
||
"/vms",
|
||
pfadFuer("sdn", {}),
|
||
...onlineNodes.map((node) => pfadFuer("node", { node })),
|
||
...gastStorages.map((storage) => pfadFuer("storage", { storage })),
|
||
];
|
||
const rechte: RechteJePfad = {};
|
||
for (const p of pfade) {
|
||
const antwort = await versuchen(warnungen, `Rechte auf ${p}`, () =>
|
||
client.get<RechteJePfad>("/access/permissions", { path: p }),
|
||
);
|
||
rechte[p] = antwort?.[p] ?? {};
|
||
}
|
||
const fehlendeRechte = rechtePruefen(pveVersion, rechte, { storages: gastStorages, nodes: onlineNodes });
|
||
|
||
// SSH je Node: nur wenn eingerichtet. Ohne gepinnten Host-Key wird erkundet, nicht genutzt.
|
||
let sshGesamt: SshZustand = "aus";
|
||
if (optionen.ssh) {
|
||
if (!optionen.ssh.privatSchluessel) {
|
||
sshGesamt = "kein_schluessel";
|
||
} else {
|
||
const zustaende: SshZustand[] = [];
|
||
for (const k of knoten.filter((k) => k.online)) {
|
||
// Einzelhost ohne Cluster-Status: die Adresse aus der Server-URL.
|
||
const host = k.ip ?? (knoten.length === 1 ? optionen.serverHost : k.name);
|
||
const pin = optionen.ssh.hostSchluessel[k.name] ?? null;
|
||
try {
|
||
const ergebnis = await sshAusfuehren(
|
||
{
|
||
host,
|
||
port: optionen.ssh.port,
|
||
benutzer: optionen.ssh.benutzer,
|
||
privatSchluessel: optionen.ssh.privatSchluessel,
|
||
hostSchluessel: pin,
|
||
},
|
||
"pveversion",
|
||
{ erkunden: !pin, zeitlimitMs: 15_000 },
|
||
);
|
||
const zustand: SshZustand = pin ? "ok" : "nicht_bestaetigt";
|
||
k.ssh = {
|
||
zustand,
|
||
host,
|
||
hostSchluessel: ergebnis.hostSchluessel,
|
||
bestaetigt: Boolean(pin),
|
||
meldung: ergebnis.stdout.trim() || null,
|
||
};
|
||
zustaende.push(zustand);
|
||
} catch (fehler) {
|
||
if (!(fehler instanceof SshFehler)) throw fehler;
|
||
k.ssh = {
|
||
zustand: "fehler",
|
||
host,
|
||
hostSchluessel: fehler.hostSchluessel ?? null,
|
||
bestaetigt: Boolean(pin),
|
||
meldung: `${fehler.ursache} ${fehler.hinweis}`,
|
||
};
|
||
zustaende.push("fehler");
|
||
}
|
||
}
|
||
sshGesamt = zustaende.includes("fehler")
|
||
? "fehler"
|
||
: zustaende.includes("nicht_bestaetigt")
|
||
? "nicht_bestaetigt"
|
||
: zustaende.length > 0
|
||
? "ok"
|
||
: "fehler";
|
||
}
|
||
}
|
||
|
||
// Im gemischten Cluster zählt die älteste Version.
|
||
const versionen = knoten.map((k) => (k.version ? versionLesen(k.version) : null)).filter((v) => v !== null);
|
||
const aelteste = versionen.reduce<PveVersion>(
|
||
(alt, v) => (v.major < alt.major || (v.major === alt.major && v.minor < alt.minor) ? v : alt),
|
||
pveVersion,
|
||
);
|
||
const alleStorages = knoten.flatMap((k) => k.storages);
|
||
|
||
return {
|
||
geprueftAm: (optionen.jetzt?.() ?? new Date()).toISOString(),
|
||
version: version.version,
|
||
release: version.release,
|
||
cluster: clusterEintrag ? { name: clusterEintrag.name, quorate: clusterEintrag.quorate === 1 } : null,
|
||
knoten,
|
||
vnets,
|
||
fehlendeRechte,
|
||
ssh: sshGesamt,
|
||
faehigkeiten: faehigkeitenAbleiten({
|
||
version: aelteste,
|
||
storages: alleStorages,
|
||
ssh: sshGesamt,
|
||
rechteFehlen: fehlendeRechte.some((r) => r.pflicht),
|
||
bauNetz: optionen.bauNetz,
|
||
}),
|
||
warnungen,
|
||
};
|
||
}
|