feat(lxc): LXC-Container ausrollen, mit oder ohne Node-SSH
Container aus dem Proxmox-Template (neueste passende Version aus aplinfo), unprivilegiert, nesting automatisch für docker. Weil Proxmox keine Exec-API für Container hat, läuft die Provisionierung per SSH: bevorzugt pct exec über den Node, sonst direkt in den Container mit einem Bereitstellungsschlüssel, der am Ende wieder entfernt wird. Auf pve-demo beide Wege geprüft: Debian 13 mit docker per pct exec (hello-world läuft mit nesting allein) und Ubuntu 24.04 per SSH in den Container. Dabei gefunden: chrony scheitert ohne CAP_SYS_TIME (jetzt if: vm), sshd -t auf Ubuntu 24.04 ohne /run/sshd. Closes #009 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -8,10 +8,14 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
## [Unveröffentlicht]
|
## [Unveröffentlicht]
|
||||||
|
|
||||||
### Hinzugefügt
|
### Hinzugefügt
|
||||||
|
- LXC-Container ausrollen: Container-Template prüfen und laden, anlegen (unprivilegiert, nesting für docker), Netz, Provisionierung per `pct exec` über Node-SSH oder per SSH in den Container mit einem Bereitstellungsschlüssel (#009)
|
||||||
|
- Profile kennen die festen Bedingungen `if: vm` und `if: lxc` (#009)
|
||||||
|
|
||||||
### Geändert
|
### Geändert
|
||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
- chrony wird in Containern nicht mehr installiert – ohne CAP_SYS_TIME scheiterte dort das Profil base (#009)
|
||||||
|
- SSH-Härtung auf Ubuntu 24.04 scheiterte an fehlendem /run/sshd (#009)
|
||||||
|
|
||||||
### Entfernt
|
### Entfernt
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
|
# Grundausstattung für jeden Gast. Platzhalter: {{ var.<name> }} aus den Variablen unten,
|
||||||
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution,
|
# {{ os.id }}, {{ os.version_id }}, {{ os.codename }} aus der Distribution,
|
||||||
# {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
|
# {{ guest.name }}, {{ guest.user }} aus dem Deployment. `a ?? b` nimmt b, wenn a leer ist.
|
||||||
|
# Bedingungen: `if: <variable>`, dazu fest `if: vm` / `if: lxc`.
|
||||||
id: base
|
id: base
|
||||||
name: Basis
|
name: Basis
|
||||||
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
|
description: Updates, Guest-Agent, Zeitzone, Locale, Tastatur, automatische Sicherheitsupdates, Zeitsync, SSH-Härtung
|
||||||
@@ -16,8 +17,12 @@ families:
|
|||||||
- timezone: "{{ var.zeitzone }}"
|
- timezone: "{{ var.zeitzone }}"
|
||||||
- locale: "{{ var.locale }}"
|
- locale: "{{ var.locale }}"
|
||||||
- keyboard: "{{ var.tastatur }}"
|
- keyboard: "{{ var.tastatur }}"
|
||||||
- packages: [chrony, sudo, curl, ca-certificates]
|
- packages: [sudo, curl, ca-certificates]
|
||||||
|
# Zeitsync nur in VMs: ein unprivilegierter Container darf die Uhr nicht stellen, er nimmt die des Hosts.
|
||||||
|
- packages: [chrony]
|
||||||
|
if: vm
|
||||||
- service: { name: chrony, enable: true, start: true }
|
- service: { name: chrony, enable: true, start: true }
|
||||||
|
if: vm
|
||||||
- unattended_upgrades: true
|
- unattended_upgrades: true
|
||||||
- ssh_harden: { password_auth: false, root_login: false }
|
- ssh_harden: { password_auth: false, root_login: false }
|
||||||
- packages: [fail2ban]
|
- packages: [fail2ban]
|
||||||
|
|||||||
+11
-6
@@ -1,21 +1,26 @@
|
|||||||
# #009 LXC-Container ausrollen
|
# #009 LXC-Container ausrollen
|
||||||
**Status:** offen
|
**Status:** erledigt
|
||||||
**Meilenstein:** v0.3.0
|
**Meilenstein:** v0.3.0
|
||||||
**Labels:** feature, proxmox
|
**Labels:** feature, proxmox
|
||||||
**Geschätzt:** 8 h **Tatsächlich:** –
|
**Geschätzt:** 8 h **Tatsächlich:** 0 h 30 min
|
||||||
|
|
||||||
## Ziel
|
## Ziel
|
||||||
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
|
LXC aus Proxmox-Template, Provisionierung per pct exec (SSH zum Node) oder SSH in den Container.
|
||||||
|
|
||||||
## Aufgaben
|
## Aufgaben
|
||||||
- [ ] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
|
- [x] aplinfo prüfen/herunterladen, Planer LXC, Swap, unprivilegiert, Features (nesting per API, keyctl nur per SSH)
|
||||||
- [ ] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
|
- [x] Transport `ssh-node` (pct exec) und `ssh-gast` (Bereitstellungsschlüssel, danach entfernen)
|
||||||
|
|
||||||
## Akzeptanzkriterien
|
## Akzeptanzkriterien
|
||||||
- [ ] LXC mit base auf echtem Proxmox, einmal mit und einmal ohne Node-SSH
|
- [x] Auf pve-demo: pd-ct01 (Debian 13, base+docker, pct exec, docker hello-world im unprivilegierten CT mit nesting) und pd-ct02 (Ubuntu 24.04, base, SSH in den Container, Bereitstellungsschlüssel danach entfernt)
|
||||||
|
|
||||||
## Abhängigkeiten
|
## Abhängigkeiten
|
||||||
#008
|
#008
|
||||||
|
|
||||||
## Notizen / Entscheidungen
|
## Notizen / Entscheidungen
|
||||||
–
|
- **Fund:** chrony läuft in unprivilegierten Containern nicht (keine CAP_SYS_TIME). Profile kennen jetzt die festen Bedingungen `if: vm` / `if: lxc`; base installiert chrony nur in VMs.
|
||||||
|
- **Fund:** Ubuntu 24.04 startet sshd per Socket, `/run/sshd` fehlt bis zur ersten Verbindung – `sshd -t` scheiterte. Der Adapter legt das Verzeichnis vorher an.
|
||||||
|
- **Docker im unprivilegierten Container braucht nur nesting**, kein keyctl (geprüft auf PVE 9.2.2 mit Debian 13).
|
||||||
|
- **keyctl** darf per Token nicht gesetzt werden; mit Node-SSH setzt ProxDeploy es per `pct set`, ohne lehnt die Prüfung ab.
|
||||||
|
- **Bereitstellungsschlüssel** (eigener ed25519, verschlüsselt in den Einstellungen) nur für den Weg ohne Node-SSH; die Schlüssel des Benutzers gehen nie an root.
|
||||||
|
- Bei mehreren passenden Container-Templates wird das neueste aus `aplinfo` geladen, wenn lokal nur ein älteres liegt.
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
|
| [#006](006-auftraege-sse.md) | Aufträge, Phasen, worker und Live-Fortschritt | erledigt | v0.2.0 | 8 h | 0 h 15 min |
|
||||||
| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
|
| [#007](007-template-bau.md) | Template-Bau für VMs | erledigt | v0.2.0 | 8 h | 0 h 12 min |
|
||||||
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
|
| [#008](008-vm-ausrollen.md) | Deploy-Assistent und VM ausrollen | erledigt | v0.2.0 | 10 h | 0 h 36 min |
|
||||||
| [#009](009-lxc.md) | LXC-Container ausrollen | offen | v0.3.0 | 8 h | – |
|
| [#009](009-lxc.md) | LXC-Container ausrollen | erledigt | v0.3.0 | 8 h | 0 h 30 min |
|
||||||
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
|
| [#010](010-ubuntu.md) | Ubuntu 22.04, 24.04 und 26.04 | offen | v0.3.0 | 3 h | – |
|
||||||
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
|
| [#011](011-profile-docker-zabbix.md) | Profile docker und zabbix-agent, Variablen, Editor | offen | v0.3.0 | 6 h | – |
|
||||||
| [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 h | – |
|
| [#012](012-vorlagen.md) | Vorlagen (Presets) | offen | v0.3.0 | 3 h | – |
|
||||||
@@ -25,3 +25,4 @@ Details und Architektur: [../architektur.md](../architektur.md). Zeiten werden a
|
|||||||
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
| [#019](019-installer.md) | Docker-Image und LXC-Installer | offen | v0.6.0 | 6 h | – |
|
||||||
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
| [#020](020-feldtest.md) | Feldtest 8.x und 9.x, Version 1.0.0 | offen | v1.0.0 | 6 h | – |
|
||||||
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
|
| [#021](021-template-bau-ohne-ssh.md) | Template-Bau ohne SSH (Bau-Adresse) | offen | v0.3.0 | 3 h | – |
|
||||||
|
| [#022](022-template-profile.md) | Templates mit eingebackenen Profilen | offen | v0.3.0 | 4 h | – |
|
||||||
|
|||||||
+2
-1
@@ -99,7 +99,8 @@ families:
|
|||||||
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
|
| `run: <befehl>` | freier Shell-Befehl – nicht portabel, nur wenn nichts anderes passt |
|
||||||
|
|
||||||
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
|
Jeder Schritt kann `if: <variable>` (oder `if: "!<variable>"`) tragen und läuft dann nur, wenn die
|
||||||
Variable wahr bzw. falsch ist.
|
Variable wahr bzw. falsch ist. Fest eingebaut sind `if: vm` und `if: lxc` – etwa für Zeitsync, die
|
||||||
|
in einem unprivilegierten Container nicht laufen kann.
|
||||||
|
|
||||||
### Platzhalter
|
### Platzhalter
|
||||||
|
|
||||||
|
|||||||
@@ -465,7 +465,6 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
className={clsx(
|
className={clsx(
|
||||||
"border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4",
|
"border-linie flex cursor-pointer flex-col gap-1 rounded-lg border p-4",
|
||||||
werte.typ === wert && "border-akzent",
|
werte.typ === wert && "border-akzent",
|
||||||
wert === "lxc" && "cursor-not-allowed opacity-50",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2 font-semibold">
|
<span className="flex items-center gap-2 font-semibold">
|
||||||
@@ -555,7 +554,7 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</Feld>
|
</Feld>
|
||||||
<Feld id="vmid" label="VMID" hilfe="Leer = nächste freie">
|
<Feld id="vmid" label={werte.typ === "lxc" ? "CTID" : "VMID"} hilfe="Leer = nächste freie">
|
||||||
<input
|
<input
|
||||||
id="vmid"
|
id="vmid"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
@@ -631,6 +630,40 @@ export function Assistent({ daten, vorauswahl }: { daten: AssistentDaten; voraus
|
|||||||
</select>
|
</select>
|
||||||
</Feld>
|
</Feld>
|
||||||
</div>
|
</div>
|
||||||
|
{werte.typ === "lxc" ? (
|
||||||
|
<div className="mt-5 grid gap-4 sm:grid-cols-3">
|
||||||
|
<Feld id="swap" label="Swap (MB)">
|
||||||
|
<input
|
||||||
|
id="swap"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={256}
|
||||||
|
className={EINGABE_KLASSE}
|
||||||
|
value={werte.swapMb}
|
||||||
|
onChange={(e) => setze({ swapMb: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Feld>
|
||||||
|
<Schalter
|
||||||
|
id="unprivilegiert"
|
||||||
|
label="Unprivilegiert"
|
||||||
|
hilfe="Standard und empfohlen. Privilegierte Container braucht es selten."
|
||||||
|
checked={werte.unprivilegiert}
|
||||||
|
onChange={(an) => setze({ unprivilegiert: an })}
|
||||||
|
/>
|
||||||
|
<Schalter
|
||||||
|
id="nesting"
|
||||||
|
label="nesting"
|
||||||
|
hilfe={
|
||||||
|
werte.profile.includes("docker")
|
||||||
|
? "Das Profil docker schaltet es ohnehin ein."
|
||||||
|
: "Für Docker oder systemd-Dienste mit eigenem Namensraum."
|
||||||
|
}
|
||||||
|
checked={werte.nesting || werte.profile.includes("docker")}
|
||||||
|
disabled={werte.profile.includes("docker")}
|
||||||
|
onChange={(an) => setze({ nesting: an })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Karte>
|
</Karte>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,444 @@
|
|||||||
|
import { AblaufFehler } from "@/engine/ablauf/fehler";
|
||||||
|
import type { PhasenKontext, PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||||
|
import { adapterFuer } from "@/engine/adapter";
|
||||||
|
import { templateWaehlen } from "@/engine/katalog/lxc-template";
|
||||||
|
import type { Distribution } from "@/engine/katalog/schema";
|
||||||
|
import type { AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
|
import { MARKE, profileRendern } from "@/engine/profile/rendern";
|
||||||
|
import { aufgabeAbwarten } from "@/engine/proxmox/aufgaben";
|
||||||
|
import { pfad, type ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { q } from "@/engine/shell";
|
||||||
|
import { SshFehler, sshAusfuehren } from "@/engine/ssh/verbindung";
|
||||||
|
import type { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
|
import type { NodeBefehl } from "./template-bau";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Einen LXC-Container ausrollen. Anlegen, Netz und Start gehen per API; Befehle im Container
|
||||||
|
* nicht – Proxmox hat dafür keine API (geprüft gegen das Schema von PVE 9.2). Provisioniert wird
|
||||||
|
* deshalb per SSH, auf einem von zwei Wegen:
|
||||||
|
*
|
||||||
|
* - **Node**: `pct exec <ctid> -- bash -s` über SSH zum Node. Braucht keinen Netzweg zum Container.
|
||||||
|
* - **Gast**: SSH als root direkt in den Container mit dem ProxDeploy-Bereitstellungsschlüssel, den
|
||||||
|
* das Anlegen per `ssh-public-keys` hinterlegt; am Ende wird er wieder entfernt.
|
||||||
|
*/
|
||||||
|
export type Bereitstellungsschluessel = { privat: string; oeffentlich: string };
|
||||||
|
|
||||||
|
export type LxcAusrollEingabe = {
|
||||||
|
client: ProxmoxClient;
|
||||||
|
anfrage: AusrollAnfrage;
|
||||||
|
distribution: Distribution;
|
||||||
|
profile: AufgeloestesProfil[];
|
||||||
|
/** Container-Features, die Profile verlangen (z. B. docker → nesting). */
|
||||||
|
profilFeatures: { nesting: boolean; keyctl: boolean };
|
||||||
|
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
|
||||||
|
sshFuer: (knoten: string) => NodeBefehl | null;
|
||||||
|
bereitstellung: () => Promise<Bereitstellungsschluessel>;
|
||||||
|
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
|
||||||
|
auftragKurzId: string;
|
||||||
|
warten?: (ms: number) => Promise<void>;
|
||||||
|
netzMs?: number;
|
||||||
|
/** Nur für Tests: SSH in den Gast ersetzen. */
|
||||||
|
gastSsh?: (
|
||||||
|
ziel: { host: string; schluessel: Bereitstellungsschluessel },
|
||||||
|
skript: string,
|
||||||
|
beiAusgabe: (t: string) => void,
|
||||||
|
warten: (ms: number) => Promise<void>,
|
||||||
|
) => Promise<number | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LxcAusrollStand = {
|
||||||
|
knoten?: string;
|
||||||
|
vmid?: number;
|
||||||
|
ostemplate?: string;
|
||||||
|
vztmplStorage?: string;
|
||||||
|
ip?: string;
|
||||||
|
weg?: "node" | "gast";
|
||||||
|
};
|
||||||
|
|
||||||
|
type Ressource = {
|
||||||
|
vmid?: number;
|
||||||
|
node?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
maxmem?: number;
|
||||||
|
mem?: number;
|
||||||
|
};
|
||||||
|
type StorageEintrag = { storage: string; content: string; active?: number; enabled?: number };
|
||||||
|
type Aplinfo = { template: string; section?: string; package?: string };
|
||||||
|
|
||||||
|
const schlafen = (ms: number) => new Promise<void>((erfuellen) => setTimeout(erfuellen, ms));
|
||||||
|
|
||||||
|
/** Markenzeilen der Provisionierung in Detail und Log übersetzen – für beide Wege gleich. */
|
||||||
|
export function ausgabeVerfolgen(k: PhasenKontext) {
|
||||||
|
let rest = "";
|
||||||
|
let profil = "";
|
||||||
|
const beginn = Date.now();
|
||||||
|
return {
|
||||||
|
get profil() {
|
||||||
|
return profil;
|
||||||
|
},
|
||||||
|
weiter(text: string) {
|
||||||
|
rest += text;
|
||||||
|
const zeilen = rest.split("\n");
|
||||||
|
rest = zeilen.pop() ?? "";
|
||||||
|
for (const zeile of zeilen) {
|
||||||
|
const marke = new RegExp(`^${MARKE} profil (.+)$`).exec(zeile);
|
||||||
|
if (marke) {
|
||||||
|
profil = marke[1]!;
|
||||||
|
const s = Math.round((Date.now() - beginn) / 1000);
|
||||||
|
void k.detail(
|
||||||
|
`Profil ${profil} läuft … ${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
void k.log(zeile, zeile.startsWith(MARKE) ? "info" : "debug");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lxcNetz(netz: AusrollAnfrage["netz"]): string {
|
||||||
|
return [
|
||||||
|
"name=eth0",
|
||||||
|
`bridge=${netz.bridge}`,
|
||||||
|
...(netz.vlan ? [`tag=${netz.vlan}`] : []),
|
||||||
|
netz.modus === "dhcp" ? "ip=dhcp" : `ip=${netz.ip}`,
|
||||||
|
...(netz.modus === "statisch" && netz.gateway ? [`gw=${netz.gateway}`] : []),
|
||||||
|
"type=veth",
|
||||||
|
].join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lxcAusrollSchritte(e: LxcAusrollEingabe, stand: LxcAusrollStand = {}): PhasenSchritt[] {
|
||||||
|
const { client, anfrage: a, distribution: d } = e;
|
||||||
|
const warten = e.warten ?? schlafen;
|
||||||
|
const lxc = d.lxc;
|
||||||
|
const ctPfad = () => pfad`/nodes/${stand.knoten!}/lxc/${stand.vmid!}`;
|
||||||
|
const features = {
|
||||||
|
nesting: a.features.nesting || e.profilFeatures.nesting,
|
||||||
|
keyctl: a.features.keyctl || e.profilFeatures.keyctl,
|
||||||
|
};
|
||||||
|
let bereitstellung: Bereitstellungsschluessel | undefined;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
schluessel: "pruefungen",
|
||||||
|
titel: "Prüfungen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
if (!lxc)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`${d.name} gibt es nicht als LXC-Container.`,
|
||||||
|
"Eine andere Distribution wählen.",
|
||||||
|
);
|
||||||
|
const ressourcen = await client.get<Ressource[]>("/cluster/resources");
|
||||||
|
const nodes = ressourcen.filter((r) => r.type === "node" && r.status === "online");
|
||||||
|
stand.knoten =
|
||||||
|
a.knoten ??
|
||||||
|
[...nodes].sort((x, y) => (y.maxmem ?? 0) - (y.mem ?? 0) - ((x.maxmem ?? 0) - (x.mem ?? 0)))[0]
|
||||||
|
?.node;
|
||||||
|
if (!stand.knoten || !nodes.some((n) => n.node === stand.knoten)) {
|
||||||
|
throw new AblaufFehler(`Node ${stand.knoten ?? "?"} ist nicht online.`, "Anderen Node wählen.");
|
||||||
|
}
|
||||||
|
const storages = await client.get<StorageEintrag[]>(pfad`/nodes/${stand.knoten}/storage`);
|
||||||
|
const aktiv = (s: StorageEintrag) => s.active === 1 && s.enabled !== 0;
|
||||||
|
const ziel = storages.find((s) => s.storage === a.storage);
|
||||||
|
if (!ziel || !aktiv(ziel) || !ziel.content.split(",").includes("rootdir")) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Storage ${a.storage} nimmt auf ${stand.knoten} keine Container auf.`,
|
||||||
|
`Geeignet sind: ${
|
||||||
|
storages
|
||||||
|
.filter((s) => aktiv(s) && s.content.includes("rootdir"))
|
||||||
|
.map((s) => s.storage)
|
||||||
|
.join(", ") || "keiner"
|
||||||
|
}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
stand.vztmplStorage = storages.find(
|
||||||
|
(s) => aktiv(s) && s.content.split(",").includes("vztmpl"),
|
||||||
|
)?.storage;
|
||||||
|
if (!stand.vztmplStorage)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
"Kein Storage mit Inhaltstyp „Container-Templates“.",
|
||||||
|
"Einem Dir-Storage den Inhaltstyp vztmpl geben.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const netze = await client.get<Array<{ iface: string; type: string; bridge_vlan_aware?: number }>>(
|
||||||
|
pfad`/nodes/${stand.knoten}/network`,
|
||||||
|
);
|
||||||
|
const bridge = netze.find(
|
||||||
|
(n) => n.iface === a.netz.bridge && (n.type === "bridge" || n.type === "OVSBridge"),
|
||||||
|
);
|
||||||
|
if (!bridge)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Bridge ${a.netz.bridge} gibt es auf ${stand.knoten} nicht.`,
|
||||||
|
"Andere Bridge wählen.",
|
||||||
|
);
|
||||||
|
if (a.netz.vlan && bridge.type === "bridge" && bridge.bridge_vlan_aware !== 1) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Bridge ${a.netz.bridge} ist nicht VLAN-fähig.`,
|
||||||
|
"„VLAN aware“ an der Bridge einschalten oder ohne VLAN ausrollen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provisionierung braucht SSH: lieber zum Node (kein Netzweg zum Container nötig).
|
||||||
|
stand.weg = e.sshFuer(stand.knoten) ? "node" : "gast";
|
||||||
|
if (features.keyctl && stand.weg !== "node") {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
"Das Feature keyctl darf per API-Token nicht gesetzt werden.",
|
||||||
|
"SSH zum Node einrichten (dann setzt ProxDeploy es per pct set) oder ohne keyctl ausrollen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const belegt = new Set(
|
||||||
|
ressourcen.map((r) => r.vmid).filter((v): v is number => typeof v === "number"),
|
||||||
|
);
|
||||||
|
stand.vmid = await e.vmidReservieren(belegt, a.vmid);
|
||||||
|
await e.angelegt({ knoten: stand.knoten, typ: "LXC" });
|
||||||
|
return {
|
||||||
|
detail: `CTID ${stand.vmid} frei · ${a.storage} · ${a.netz.bridge}${a.netz.vlan ? ` VLAN ${a.netz.vlan}` : ""} · Provisionierung über ${stand.weg === "node" ? "pct exec" : "SSH in den Container"}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "lxc-template",
|
||||||
|
titel: "LXC-Template",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
const vorhanden = await client.get<Array<{ volid: string }>>(
|
||||||
|
pfad`/nodes/${stand.knoten!}/storage/${stand.vztmplStorage!}/content`,
|
||||||
|
{
|
||||||
|
content: "vztmpl",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const dateien = vorhanden.map((v) => v.volid.split("/").pop()!);
|
||||||
|
const lokal = templateWaehlen(lxc!.template_pattern, dateien);
|
||||||
|
// Was es zum Herunterladen gibt – ist dort eine neuere Version, wird sie geladen.
|
||||||
|
const verfuegbar = (await client.get<Aplinfo[]>(pfad`/nodes/${stand.knoten!}/aplinfo`)).map(
|
||||||
|
(x) => x.template,
|
||||||
|
);
|
||||||
|
const neueste = templateWaehlen(lxc!.template_pattern, verfuegbar);
|
||||||
|
if (lokal && (!neueste || lokal === neueste)) {
|
||||||
|
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`;
|
||||||
|
return { uebersprungen: true, detail: `${lokal} vorhanden` };
|
||||||
|
}
|
||||||
|
if (!neueste) {
|
||||||
|
if (lokal) {
|
||||||
|
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${lokal}`;
|
||||||
|
return { uebersprungen: true, detail: `${lokal} vorhanden` };
|
||||||
|
}
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Kein Container-Template passt zu „${lxc!.template_pattern}“.`,
|
||||||
|
"Mit „pveam update“ die Liste auf dem Node auffrischen oder das Muster im Katalog prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await aufgabeAbwarten(
|
||||||
|
client,
|
||||||
|
await client.post<string>(pfad`/nodes/${stand.knoten!}/aplinfo`, {
|
||||||
|
storage: stand.vztmplStorage!,
|
||||||
|
template: neueste,
|
||||||
|
}),
|
||||||
|
{ zeitlimitMs: 30 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
stand.ostemplate = `${stand.vztmplStorage}:vztmpl/${neueste}`;
|
||||||
|
return { detail: `${neueste} geladen` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "anlegen",
|
||||||
|
titel: "Anlegen",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
if (stand.weg === "gast") bereitstellung = await e.bereitstellung();
|
||||||
|
const featureListe = [features.nesting ? "nesting=1" : null].filter(Boolean).join(",");
|
||||||
|
await aufgabeAbwarten(
|
||||||
|
client,
|
||||||
|
await client.post<string>(pfad`/nodes/${stand.knoten!}/lxc`, {
|
||||||
|
vmid: stand.vmid!,
|
||||||
|
hostname: a.name,
|
||||||
|
ostemplate: stand.ostemplate!,
|
||||||
|
ostype: lxc!.ostype,
|
||||||
|
rootfs: `${a.storage}:${a.diskGb}`,
|
||||||
|
cores: a.cores,
|
||||||
|
memory: a.speicherMb,
|
||||||
|
swap: a.swapMb,
|
||||||
|
unprivileged: a.unprivilegiert,
|
||||||
|
...(featureListe ? { features: featureListe } : {}),
|
||||||
|
net0: lxcNetz(a.netz),
|
||||||
|
...(a.netz.dns.length > 0 ? { nameserver: a.netz.dns.join(" ") } : {}),
|
||||||
|
...(a.netz.suchdomain ? { searchdomain: a.netz.suchdomain } : {}),
|
||||||
|
// Nur der Bereitstellungsschlüssel kommt an root – der Benutzer bekommt seine eigenen Schlüssel.
|
||||||
|
...(bereitstellung ? { "ssh-public-keys": bereitstellung.oeffentlich } : {}),
|
||||||
|
onboot: a.startBeiBoot,
|
||||||
|
tags: ["proxdeploy", d.id, ...a.tags].join(";"),
|
||||||
|
description: `Ausgerollt mit ProxDeploy (Auftrag ${e.auftragKurzId})\nDistribution: ${d.name}\nProfile: ${e.profile.map((p) => p.profil.id).join(", ") || "–"}`,
|
||||||
|
}),
|
||||||
|
{ zeitlimitMs: 15 * 60 * 1000 },
|
||||||
|
);
|
||||||
|
await e.angelegt({ vmid: stand.vmid });
|
||||||
|
if (features.keyctl) {
|
||||||
|
const ssh = e.sshFuer(stand.knoten!)!;
|
||||||
|
if (!client.trocken) {
|
||||||
|
const ergebnis = await ssh(
|
||||||
|
`pct set ${stand.vmid} --features ${q(`nesting=${features.nesting ? 1 : 0},keyctl=1`)}`,
|
||||||
|
);
|
||||||
|
if (ergebnis.code !== 0)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`keyctl ließ sich nicht setzen: ${ergebnis.stderr.trim()}`,
|
||||||
|
"Rechte des SSH-Benutzers prüfen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail: `${a.unprivilegiert ? "unprivilegiert" : "privilegiert"} · ${a.cores} CPU · ${a.speicherMb} MB · ${a.diskGb}G${
|
||||||
|
features.nesting || features.keyctl
|
||||||
|
? ` · ${[features.nesting && "nesting", features.keyctl && "keyctl"].filter(Boolean).join(", ")}`
|
||||||
|
: ""
|
||||||
|
}`,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "start",
|
||||||
|
titel: "Start",
|
||||||
|
ausfuehren: async () => {
|
||||||
|
await aufgabeAbwarten(client, await client.post<string>(`${ctPfad()}/status/start`), {
|
||||||
|
zeitlimitMs: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
return { detail: `CT ${stand.vmid} läuft` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "netzwerk",
|
||||||
|
titel: "Netzwerk",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
if (a.netz.modus === "statisch") {
|
||||||
|
stand.ip = a.netz.ip!.split("/")[0];
|
||||||
|
return { detail: `IP ${stand.ip}` };
|
||||||
|
}
|
||||||
|
if (client.trocken) return { detail: "IP per DHCP" };
|
||||||
|
const beginn = Date.now();
|
||||||
|
const grenze = e.netzMs ?? 2 * 60 * 1000;
|
||||||
|
while (!stand.ip) {
|
||||||
|
await k.pruefeAbbruch();
|
||||||
|
const schnittstellen = await client.get<Array<{ name: string; inet?: string }>>(
|
||||||
|
`${ctPfad()}/interfaces`,
|
||||||
|
);
|
||||||
|
stand.ip = schnittstellen.find((s) => s.name === "eth0" && s.inet)?.inet?.split("/")[0];
|
||||||
|
if (stand.ip) break;
|
||||||
|
if (Date.now() - beginn > grenze) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
"Der Container hat per DHCP keine Adresse bekommen.",
|
||||||
|
"DHCP auf der Bridge bzw. im VLAN prüfen oder eine statische Adresse wählen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await k.detail(`wartet auf DHCP … ${Math.round((Date.now() - beginn) / 1000)}s`);
|
||||||
|
await warten(2000);
|
||||||
|
}
|
||||||
|
return { detail: `IP ${stand.ip} per DHCP` };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "provisionierung",
|
||||||
|
titel: "Provisionierung",
|
||||||
|
ausfuehren: async (k) => {
|
||||||
|
const adapter = adapterFuer(d.family);
|
||||||
|
if (!adapter)
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Für die Familie „${d.family}“ gibt es keinen Adapter.`,
|
||||||
|
"Adapter ergänzen (docs/katalog.md).",
|
||||||
|
);
|
||||||
|
const nachspann =
|
||||||
|
stand.weg === "gast"
|
||||||
|
? [
|
||||||
|
`echo '${MARKE} bereitstellung-entfernen'\nsed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys\n`,
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
const { skript } = profileRendern(
|
||||||
|
e.profile,
|
||||||
|
adapter,
|
||||||
|
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "lxc" } },
|
||||||
|
[
|
||||||
|
adapter.benutzerAnlegen({
|
||||||
|
name: a.zugang.benutzer,
|
||||||
|
sshSchluessel: a.zugang.sshSchluessel,
|
||||||
|
sudo: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const gesamt = skript + nachspann.join("");
|
||||||
|
if (client.trocken) {
|
||||||
|
await k.log(gesamt, "info");
|
||||||
|
return {
|
||||||
|
detail: `über ${stand.weg === "node" ? "pct exec" : "SSH"}${e.profile.length > 0 ? ` · Profile ${e.profile.map((p) => p.profil.id).join(", ")}` : ""}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const verfolgen = ausgabeVerfolgen(k);
|
||||||
|
let code: number | null;
|
||||||
|
if (stand.weg === "node") {
|
||||||
|
const ssh = e.sshFuer(stand.knoten!)!;
|
||||||
|
code = (
|
||||||
|
await ssh(`pct exec ${stand.vmid} -- bash -s`, {
|
||||||
|
eingabe: gesamt,
|
||||||
|
zeitlimitMs: 60 * 60 * 1000,
|
||||||
|
beiAusgabe: (t) => verfolgen.weiter(t),
|
||||||
|
})
|
||||||
|
).code;
|
||||||
|
} else {
|
||||||
|
const ziel = { host: stand.ip!, schluessel: bereitstellung! };
|
||||||
|
code = await (e.gastSsh ?? gastSshStandard)(ziel, gesamt, (t) => verfolgen.weiter(t), warten);
|
||||||
|
}
|
||||||
|
verfolgen.weiter("\n");
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`Provisionierung ${verfolgen.profil ? `im Profil ${verfolgen.profil} ` : ""}mit Code ${code} abgebrochen.`,
|
||||||
|
"Detail-Log ansehen.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
detail:
|
||||||
|
e.profile.length > 0
|
||||||
|
? `Profile ${e.profile.map((p) => p.profil.id).join(", ")} angewendet`
|
||||||
|
: "Benutzer eingerichtet",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSH als root in den frischen Container. Seinen Host-Key kann ProxDeploy vorher nicht kennen –
|
||||||
|
* er entsteht beim ersten Start. Vertraut wird deshalb dem ersten Schlüssel (TOFU), und das nur für
|
||||||
|
* diese eine Verbindung; der Bereitstellungsschlüssel wird am Ende des Skripts wieder entfernt.
|
||||||
|
*/
|
||||||
|
async function gastSshStandard(
|
||||||
|
ziel: { host: string; schluessel: Bereitstellungsschluessel },
|
||||||
|
skript: string,
|
||||||
|
beiAusgabe: (t: string) => void,
|
||||||
|
warten: (ms: number) => Promise<void> = schlafen,
|
||||||
|
): Promise<number | null> {
|
||||||
|
const beginn = Date.now();
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const ergebnis = await sshAusfuehren(
|
||||||
|
{
|
||||||
|
host: ziel.host,
|
||||||
|
port: 22,
|
||||||
|
benutzer: "root",
|
||||||
|
privatSchluessel: ziel.schluessel.privat,
|
||||||
|
hostSchluessel: null,
|
||||||
|
},
|
||||||
|
"bash -s",
|
||||||
|
{ erkunden: true, eingabe: skript, zeitlimitMs: 60 * 60 * 1000, beiAusgabe },
|
||||||
|
);
|
||||||
|
return ergebnis.code;
|
||||||
|
} catch (fehler) {
|
||||||
|
// sshd braucht nach dem Start ein paar Sekunden.
|
||||||
|
if (fehler instanceof SshFehler && fehler.art === "verbindung" && Date.now() - beginn < 90_000) {
|
||||||
|
await warten(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fehler instanceof SshFehler) {
|
||||||
|
throw new AblaufFehler(
|
||||||
|
`SSH in den Container ${ziel.host} fehlgeschlagen: ${fehler.ursache}`,
|
||||||
|
"Ist der Container aus dem Netz von ProxDeploy erreichbar? Sonst SSH zum Node einrichten (pct exec).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw fehler;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ import { q } from "@/engine/shell";
|
|||||||
/** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */
|
/** Führt einen Befehl als root auf einem Node aus (ssh2, gepinnter Host-Key). */
|
||||||
export type NodeBefehl = (
|
export type NodeBefehl = (
|
||||||
befehl: string,
|
befehl: string,
|
||||||
optionen?: { eingabe?: string; zeitlimitMs?: number },
|
optionen?: { eingabe?: string; zeitlimitMs?: number; beiAusgabe?: (text: string) => void },
|
||||||
) => Promise<{ code: number | null; stdout: string; stderr: string }>;
|
) => Promise<{ code: number | null; stdout: string; stderr: string }>;
|
||||||
|
|
||||||
export type TemplateBauEingabe = {
|
export type TemplateBauEingabe = {
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
|||||||
const { skript } = profileRendern(
|
const { skript } = profileRendern(
|
||||||
e.profile,
|
e.profile,
|
||||||
adapter,
|
adapter,
|
||||||
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer } },
|
{ os: d.os, gast: { name: a.name, benutzer: a.zugang.benutzer, typ: "vm" } },
|
||||||
[
|
[
|
||||||
adapter.benutzerAnlegen({
|
adapter.benutzerAnlegen({
|
||||||
name: a.zugang.benutzer,
|
name: a.zugang.benutzer,
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
|
|||||||
PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"}
|
PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"}
|
||||||
PROXDEPLOY_SSH
|
PROXDEPLOY_SSH
|
||||||
if command -v sshd >/dev/null 2>&1; then
|
if command -v sshd >/dev/null 2>&1; then
|
||||||
|
# Ubuntu 24.04 startet sshd per Socket – bis zur ersten Verbindung fehlt /run/sshd, und sshd -t bricht ab.
|
||||||
|
install -d -m 0755 /run/sshd
|
||||||
sshd -t
|
sshd -t
|
||||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
|
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ export type Distribution = z.infer<typeof DistributionSchema>;
|
|||||||
|
|
||||||
export const VariableSchema = z
|
export const VariableSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich."),
|
name: z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z][a-z0-9_]*$/, "Variablennamen: klein, mit Unterstrich.")
|
||||||
|
.refine((n) => n !== "vm" && n !== "lxc", "vm und lxc sind feste Bedingungen, keine Variablennamen."),
|
||||||
label: z.string().min(1),
|
label: z.string().min(1),
|
||||||
type: z.enum(["string", "number", "boolean", "select"]).default("string"),
|
type: z.enum(["string", "number", "boolean", "select"]).default("string"),
|
||||||
required: z.boolean().default(false),
|
required: z.boolean().default(false),
|
||||||
|
|||||||
@@ -14,17 +14,26 @@ export const MARKE = "::proxdeploy::";
|
|||||||
|
|
||||||
export type RenderKontext = {
|
export type RenderKontext = {
|
||||||
os: { id: string; version_id: string; codename?: string };
|
os: { id: string; version_id: string; codename?: string };
|
||||||
gast: { name: string; benutzer: string };
|
gast: { name: string; benutzer: string; typ?: "vm" | "lxc" };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Feste Bedingungen neben den Profil-Variablen: `if: vm` / `if: lxc` (bzw. `!vm`, `!lxc`). */
|
||||||
|
export const FESTE_BEDINGUNGEN = ["vm", "lxc"] as const;
|
||||||
|
|
||||||
export type Abschnitt = { profil: string; skript: string };
|
export type Abschnitt = { profil: string; skript: string };
|
||||||
|
|
||||||
export type Gerendert = { skript: string; abschnitte: Abschnitt[] };
|
export type Gerendert = { skript: string; abschnitte: Abschnitt[] };
|
||||||
|
|
||||||
function bedingungErfuellt(schritt: ProfilSchritt, werte: Record<string, unknown>): boolean {
|
function bedingungErfuellt(
|
||||||
|
schritt: ProfilSchritt,
|
||||||
|
werte: Record<string, unknown>,
|
||||||
|
typ: "vm" | "lxc" | undefined,
|
||||||
|
): boolean {
|
||||||
if (!schritt.if) return true;
|
if (!schritt.if) return true;
|
||||||
const verneint = schritt.if.startsWith("!");
|
const verneint = schritt.if.startsWith("!");
|
||||||
const wert = Boolean(werte[schritt.if.replace(/^!/, "")]);
|
const name = schritt.if.replace(/^!/, "");
|
||||||
|
// Ohne bekannten Typ (Rendern zum Ansehen) laufen typabhängige Schritte mit.
|
||||||
|
const wert = name === "vm" || name === "lxc" ? (typ ? typ === name : !verneint) : Boolean(werte[name]);
|
||||||
return verneint ? !wert : wert;
|
return verneint ? !wert : wert;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +96,7 @@ export function profileRendern(
|
|||||||
};
|
};
|
||||||
const teile: string[] = [];
|
const teile: string[] = [];
|
||||||
for (const schritt of profil.families[adapter.familie] ?? []) {
|
for (const schritt of profil.families[adapter.familie] ?? []) {
|
||||||
if (!bedingungErfuellt(schritt, werte)) continue;
|
if (!bedingungErfuellt(schritt, werte, kontext.gast.typ)) continue;
|
||||||
const text = schrittRendern(schritt, adapter, vorlagenKontext);
|
const text = schrittRendern(schritt, adapter, vorlagenKontext);
|
||||||
if ("ssh_harden" in schritt) zumSchluss.push(text);
|
if ("ssh_harden" in schritt) zumSchluss.push(text);
|
||||||
else teile.push(text);
|
else teile.push(text);
|
||||||
|
|||||||
@@ -40,7 +40,13 @@ export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
|
|||||||
export function sshAusfuehren(
|
export function sshAusfuehren(
|
||||||
ziel: SshZiel,
|
ziel: SshZiel,
|
||||||
befehl: string,
|
befehl: string,
|
||||||
optionen: { zeitlimitMs?: number; eingabe?: string; erkunden?: boolean } = {},
|
optionen: {
|
||||||
|
zeitlimitMs?: number;
|
||||||
|
eingabe?: string;
|
||||||
|
erkunden?: boolean;
|
||||||
|
/** Laufende Ausgabe (stdout und stderr), z. B. für Live-Fortschritt einer Provisionierung. */
|
||||||
|
beiAusgabe?: (text: string) => void;
|
||||||
|
} = {},
|
||||||
): Promise<SshErgebnis> {
|
): Promise<SshErgebnis> {
|
||||||
if (!ziel.hostSchluessel && !optionen.erkunden) {
|
if (!ziel.hostSchluessel && !optionen.erkunden) {
|
||||||
return Promise.reject(
|
return Promise.reject(
|
||||||
@@ -95,8 +101,16 @@ export function sshAusfuehren(
|
|||||||
let stdout = "";
|
let stdout = "";
|
||||||
let stderr = "";
|
let stderr = "";
|
||||||
let code: number | null = null;
|
let code: number | null = null;
|
||||||
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8")));
|
kanal.on("data", (teil: Buffer) => {
|
||||||
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8")));
|
const text = teil.toString("utf8");
|
||||||
|
stdout += text;
|
||||||
|
optionen.beiAusgabe?.(text);
|
||||||
|
});
|
||||||
|
kanal.stderr.on("data", (teil: Buffer) => {
|
||||||
|
const text = teil.toString("utf8");
|
||||||
|
stderr += text;
|
||||||
|
optionen.beiAusgabe?.(text);
|
||||||
|
});
|
||||||
kanal.on("exit", (exitCode: number | null) => (code = exitCode));
|
kanal.on("exit", (exitCode: number | null) => (code = exitCode));
|
||||||
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
|
kanal.on("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
|
||||||
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
|
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import type { Server } from "@/generated/prisma/client";
|
import type { Server } from "@/generated/prisma/client";
|
||||||
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import { lxcAusrollSchritte, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen";
|
||||||
import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
|
import { vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
|
||||||
|
import type { PhasenSchritt } from "@/engine/ablauf/ablauf";
|
||||||
import type { Distribution, Profil } from "@/engine/katalog/schema";
|
import type { Distribution, Profil } from "@/engine/katalog/schema";
|
||||||
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
|
||||||
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
|
||||||
@@ -11,6 +13,7 @@ import { AnwendungsFehler } from "@/server/fehler";
|
|||||||
import { auftragAnlegen } from "@/server/services/auftraege";
|
import { auftragAnlegen } from "@/server/services/auftraege";
|
||||||
import { serverHolen, verbindungAus } from "@/server/services/server";
|
import { serverHolen, verbindungAus } from "@/server/services/server";
|
||||||
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
||||||
|
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
|
||||||
import { vmidVorschlagen } from "@/server/services/vmid";
|
import { vmidVorschlagen } from "@/server/services/vmid";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,14 +47,6 @@ export async function ausrollenVorbereiten(roh: unknown): Promise<Vorbereitet> {
|
|||||||
`${distribution.name} gibt es nicht als ${anfrage.typ === "vm" ? "VM" : "LXC-Container"}.`,
|
`${distribution.name} gibt es nicht als ${anfrage.typ === "vm" ? "VM" : "LXC-Container"}.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (anfrage.typ === "lxc") {
|
|
||||||
throw new AnwendungsFehler(
|
|
||||||
400,
|
|
||||||
"nicht_umgesetzt",
|
|
||||||
"LXC-Container folgen mit #009.",
|
|
||||||
"Vorerst als VM ausrollen.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const verfuegbar = new Map(
|
const verfuegbar = new Map(
|
||||||
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
|
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
|
||||||
p.id,
|
p.id,
|
||||||
@@ -92,6 +87,66 @@ export async function ausrollenStarten(roh: unknown, aufrufer: Aufrufer, ip: str
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AusrollStand = VmAusrollStand & LxcAusrollStand;
|
||||||
|
|
||||||
|
/** Container-Features, die die gewählten Profile verlangen (z. B. docker → nesting). */
|
||||||
|
export function profilFeatures(profile: AufgeloestesProfil[]): { nesting: boolean; keyctl: boolean } {
|
||||||
|
return {
|
||||||
|
nesting: profile.some((p) => p.profil.lxc.features?.nesting),
|
||||||
|
keyctl: profile.some((p) => p.profil.lxc.features?.keyctl),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Phasen für VM oder LXC – dieselben im Trockenlauf und im worker. Was sich unterscheidet
|
||||||
|
* (reservieren oder nur vorschlagen, Auftrags-ID), kommt von außen.
|
||||||
|
*/
|
||||||
|
export function ausrollSchritte(
|
||||||
|
v: Vorbereitet,
|
||||||
|
o: {
|
||||||
|
client: ProxmoxClient;
|
||||||
|
vmidReservieren: (belegt: Set<number>, wunsch?: number) => Promise<number>;
|
||||||
|
templateVmidReservieren: (belegt: Set<number>) => Promise<number>;
|
||||||
|
angelegt: (eintrag: Record<string, unknown>) => Promise<void>;
|
||||||
|
auftragKurzId: string;
|
||||||
|
},
|
||||||
|
stand: AusrollStand,
|
||||||
|
): PhasenSchritt[] {
|
||||||
|
const sshFuer = (knoten: string) => nodeBefehlFuer(v.server, knoten);
|
||||||
|
if (v.anfrage.typ === "lxc") {
|
||||||
|
return lxcAusrollSchritte(
|
||||||
|
{
|
||||||
|
client: o.client,
|
||||||
|
anfrage: v.anfrage,
|
||||||
|
distribution: v.distribution,
|
||||||
|
profile: v.profile,
|
||||||
|
profilFeatures: profilFeatures(v.profile),
|
||||||
|
vmidReservieren: o.vmidReservieren,
|
||||||
|
sshFuer,
|
||||||
|
bereitstellung: bereitstellungsschluessel,
|
||||||
|
angelegt: o.angelegt,
|
||||||
|
auftragKurzId: o.auftragKurzId,
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return vmAusrollSchritte(
|
||||||
|
{
|
||||||
|
client: o.client,
|
||||||
|
anfrage: v.anfrage,
|
||||||
|
distribution: v.distribution,
|
||||||
|
profile: v.profile,
|
||||||
|
vmidReservieren: o.vmidReservieren,
|
||||||
|
templateBau: v.anfrage.templateBauen
|
||||||
|
? { sshFuer, auftragKurzId: o.auftragKurzId, vmidReservieren: o.templateVmidReservieren }
|
||||||
|
: undefined,
|
||||||
|
angelegt: o.angelegt,
|
||||||
|
auftragKurzId: o.auftragKurzId,
|
||||||
|
},
|
||||||
|
stand,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type Plan = {
|
export type Plan = {
|
||||||
kopf: string;
|
kopf: string;
|
||||||
phasen: Array<{
|
phasen: Array<{
|
||||||
@@ -138,25 +193,17 @@ export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
|
|||||||
if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht;
|
if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const stand: VmAusrollStand = {};
|
const stand: AusrollStand = {};
|
||||||
try {
|
try {
|
||||||
const ergebnis = await phasenAusfuehren(
|
const ergebnis = await phasenAusfuehren(
|
||||||
vmAusrollSchritte(
|
ausrollSchritte(
|
||||||
|
v,
|
||||||
{
|
{
|
||||||
client,
|
client,
|
||||||
anfrage: v.anfrage,
|
|
||||||
distribution: v.distribution,
|
|
||||||
profile: v.profile,
|
|
||||||
vmidReservieren: (belegt, wunsch) =>
|
vmidReservieren: (belegt, wunsch) =>
|
||||||
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
|
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
|
||||||
templateBau: v.anfrage.templateBauen
|
templateVmidReservieren: (belegt) =>
|
||||||
? {
|
|
||||||
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
|
|
||||||
auftragKurzId: "dry-run",
|
|
||||||
vmidReservieren: (belegt) =>
|
|
||||||
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
|
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
angelegt: async () => {},
|
angelegt: async () => {},
|
||||||
auftragKurzId: "dry-run",
|
auftragKurzId: "dry-run",
|
||||||
},
|
},
|
||||||
@@ -178,11 +225,16 @@ export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function kopfzeile(v: Pick<Vorbereitet, "server" | "distribution">, stand: VmAusrollStand): string {
|
export function kopfzeile(
|
||||||
|
v: Pick<Vorbereitet, "server" | "distribution" | "anfrage">,
|
||||||
|
stand: AusrollStand,
|
||||||
|
): string {
|
||||||
return [
|
return [
|
||||||
`Server ${v.server.name}`,
|
`Server ${v.server.name}`,
|
||||||
stand.knoten ? `Node ${stand.knoten}` : null,
|
stand.knoten ? `Node ${stand.knoten}` : null,
|
||||||
`${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`,
|
v.anfrage.typ === "lxc"
|
||||||
|
? `${v.distribution.name} (LXC)`
|
||||||
|
: `${v.distribution.name}${stand.template ? ` (Template ${stand.template.vmid})` : ""}`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import ssh2 from "ssh2";
|
||||||
|
import type { Bereitstellungsschluessel } from "@/engine/ablaeufe/lxc-ausrollen";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der Bereitstellungsschlüssel: damit meldet sich ProxDeploy als root in frisch angelegten
|
||||||
|
* Containern an, wenn es keinen SSH-Zugang zum Node hat. Er steht nur während der
|
||||||
|
* Provisionierung in /root/.ssh/authorized_keys und wird am Ende des Skripts entfernt (am
|
||||||
|
* Kommentar `proxdeploy-bereitstellung` erkennbar). Einmal erzeugt, privat nur verschlüsselt.
|
||||||
|
*/
|
||||||
|
const SCHLUESSEL = "bereitstellungsschluessel";
|
||||||
|
export const KOMMENTAR = "proxdeploy-bereitstellung";
|
||||||
|
|
||||||
|
export async function bereitstellungsschluessel(): Promise<Bereitstellungsschluessel> {
|
||||||
|
const vorhanden = await prisma.einstellung.findUnique({ where: { schluessel: SCHLUESSEL } });
|
||||||
|
if (vorhanden) {
|
||||||
|
const wert = vorhanden.wert as { privat: string; oeffentlich: string };
|
||||||
|
return { privat: entschluesseln(wert.privat), oeffentlich: wert.oeffentlich };
|
||||||
|
}
|
||||||
|
const paar = ssh2.utils.generateKeyPairSync("ed25519", { comment: KOMMENTAR });
|
||||||
|
const neu = { privat: verschluesseln(paar.private), oeffentlich: paar.public.trim() };
|
||||||
|
// Zwei gleichzeitige Aufträge: der erste gewinnt, der zweite liest dessen Schlüssel.
|
||||||
|
await prisma.einstellung.upsert({
|
||||||
|
where: { schluessel: SCHLUESSEL },
|
||||||
|
create: { schluessel: SCHLUESSEL, wert: neu },
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
return await bereitstellungsschluessel();
|
||||||
|
}
|
||||||
@@ -29,6 +29,6 @@ export function nodeBefehlFuer(server: Server, knoten: string): NodeBefehl | nul
|
|||||||
hostSchluessel: pin,
|
hostSchluessel: pin,
|
||||||
},
|
},
|
||||||
befehl,
|
befehl,
|
||||||
{ eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs },
|
{ eingabe: optionen.eingabe, zeitlimitMs: optionen.zeitlimitMs, beiAusgabe: optionen.beiAusgabe },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
|
import { phasenAusfuehren } from "@/engine/ablauf/ablauf";
|
||||||
import { sshBefehl, vmAusrollSchritte, type VmAusrollStand } from "@/engine/ablaeufe/vm-ausrollen";
|
import { sshBefehl } from "@/engine/ablaeufe/vm-ausrollen";
|
||||||
import { ProxmoxClient } from "@/engine/proxmox/client";
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { melden } from "@/server/ereignisse/senke";
|
import { melden } from "@/server/ereignisse/senke";
|
||||||
import { ausrollenVorbereiten, kopfzeile } from "@/server/services/ausrollen";
|
import {
|
||||||
|
ausrollenVorbereiten,
|
||||||
|
ausrollSchritte,
|
||||||
|
kopfzeile,
|
||||||
|
type AusrollStand,
|
||||||
|
} from "@/server/services/ausrollen";
|
||||||
import { verbindungAus } from "@/server/services/server";
|
import { verbindungAus } from "@/server/services/server";
|
||||||
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
|
|
||||||
import { vmidReservieren } from "@/server/services/vmid";
|
import { vmidReservieren } from "@/server/services/vmid";
|
||||||
import { ablaufRegistrieren } from "../ausfuehren";
|
import { ablaufRegistrieren } from "../ausfuehren";
|
||||||
import { aufrufLogger } from "../aufruf-log";
|
import { aufrufLogger } from "../aufruf-log";
|
||||||
@@ -13,7 +17,7 @@ import { aufrufLogger } from "../aufruf-log";
|
|||||||
ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => {
|
ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, angelegt }) => {
|
||||||
// Dieselbe Vorbereitung wie beim Anlegen – der Katalog könnte sich seitdem geändert haben.
|
// Dieselbe Vorbereitung wie beim Anlegen – der Katalog könnte sich seitdem geändert haben.
|
||||||
const v = await ausrollenVorbereiten(auftrag.anfrage);
|
const v = await ausrollenVorbereiten(auftrag.anfrage);
|
||||||
const stand: VmAusrollStand = {};
|
const stand: AusrollStand = {};
|
||||||
const kopfSetzen = async () => {
|
const kopfSetzen = async () => {
|
||||||
await prisma.auftrag.update({
|
await prisma.auftrag.update({
|
||||||
where: { id: auftrag.id },
|
where: { id: auftrag.id },
|
||||||
@@ -26,12 +30,10 @@ ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, ange
|
|||||||
const client = new ProxmoxClient(verbindungAus(v.server), { beobachter: aufrufLogger(senke) });
|
const client = new ProxmoxClient(verbindungAus(v.server), { beobachter: aufrufLogger(senke) });
|
||||||
try {
|
try {
|
||||||
const ergebnis = await phasenAusfuehren(
|
const ergebnis = await phasenAusfuehren(
|
||||||
vmAusrollSchritte(
|
ausrollSchritte(
|
||||||
|
v,
|
||||||
{
|
{
|
||||||
client,
|
client,
|
||||||
anfrage: v.anfrage,
|
|
||||||
distribution: v.distribution,
|
|
||||||
profile: v.profile,
|
|
||||||
vmidReservieren: async (belegt, wunsch) => {
|
vmidReservieren: async (belegt, wunsch) => {
|
||||||
const vmid = await vmidReservieren({
|
const vmid = await vmidReservieren({
|
||||||
serverId: v.server.id,
|
serverId: v.server.id,
|
||||||
@@ -44,11 +46,7 @@ ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, ange
|
|||||||
await kopfSetzen();
|
await kopfSetzen();
|
||||||
return vmid;
|
return vmid;
|
||||||
},
|
},
|
||||||
templateBau: v.anfrage.templateBauen
|
templateVmidReservieren: (belegt) =>
|
||||||
? {
|
|
||||||
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
|
|
||||||
auftragKurzId: auftrag.kurzId,
|
|
||||||
vmidReservieren: (belegt) =>
|
|
||||||
vmidReservieren({
|
vmidReservieren({
|
||||||
serverId: v.server.id,
|
serverId: v.server.id,
|
||||||
auftragId: auftrag.id,
|
auftragId: auftrag.id,
|
||||||
@@ -56,8 +54,6 @@ ablaufRegistrieren("AUSROLLEN", async ({ auftrag, senke, abbruchGewuenscht, ange
|
|||||||
von: v.server.templateVmidVon,
|
von: v.server.templateVmidVon,
|
||||||
bis: v.server.templateVmidBis,
|
bis: v.server.templateVmidBis,
|
||||||
}),
|
}),
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
angelegt,
|
angelegt,
|
||||||
auftragKurzId: auftrag.kurzId,
|
auftragKurzId: auftrag.kurzId,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { phasenAusfuehren, type EreignisSenke } from "@/engine/ablauf/ablauf";
|
||||||
|
import { lxcAusrollSchritte, lxcNetz, type LxcAusrollStand } from "@/engine/ablaeufe/lxc-ausrollen";
|
||||||
|
import type { NodeBefehl } from "@/engine/ablaeufe/template-bau";
|
||||||
|
import { katalogLaden } from "@/engine/katalog/laden";
|
||||||
|
import { profileAufloesen } from "@/engine/profile/aufloesen";
|
||||||
|
import { ProxmoxClient } from "@/engine/proxmox/client";
|
||||||
|
import { AusrollAnfrage } from "@/lib/schemas/ausrollen";
|
||||||
|
import { MOCK_TOKEN_GEHEIMNIS, MOCK_TOKEN_ID, ProxmoxMock } from "../proxmox-mock/mock";
|
||||||
|
import { szenarioEinrichten } from "../proxmox-mock/szenario";
|
||||||
|
|
||||||
|
const UPID = (art: string) => `UPID:pve01:00001234:00ABCDEF:6710F00D:${art}:130:proxdeploy@pve!test:`;
|
||||||
|
const SCHLUESSEL =
|
||||||
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEFTdkCuqQ6mF7I8QzmkNaQpKj7S/I2+yuxWeYasJjLd florian@test";
|
||||||
|
const stumm: EreignisSenke = {
|
||||||
|
phasenAnlegen: async () => {},
|
||||||
|
phaseGestartet: async () => {},
|
||||||
|
phaseDetail: async () => {},
|
||||||
|
phaseBeendet: async () => {},
|
||||||
|
log: async () => {},
|
||||||
|
};
|
||||||
|
const BEREIT = { privat: "privat", oeffentlich: "ssh-ed25519 AAAAbereit proxdeploy-bereitstellung" };
|
||||||
|
|
||||||
|
const aufraeumen: Array<() => Promise<void>> = [];
|
||||||
|
afterEach(async () => {
|
||||||
|
while (aufraeumen.length) await aufraeumen.pop()!();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function aufbauen(o: { templateLokal?: string[]; dhcpNachAbfragen?: number; trocken?: boolean } = {}) {
|
||||||
|
const mock = await new ProxmoxMock().starten();
|
||||||
|
szenarioEinrichten(mock, { version: "9.2.1" });
|
||||||
|
let interfaceAbfragen = 0;
|
||||||
|
mock
|
||||||
|
.route("GET", "/cluster/resources", () => ({
|
||||||
|
data: [{ type: "node", node: "pve01", status: "online", maxmem: 64e9, mem: 1e9 }, { vmid: 100 }],
|
||||||
|
}))
|
||||||
|
.route("GET", /^\/nodes\/pve01\/storage\/local\/content$/, () => ({
|
||||||
|
data: (o.templateLokal ?? []).map((t) => ({ volid: `local:vztmpl/${t}` })),
|
||||||
|
}))
|
||||||
|
.route("GET", "/nodes/pve01/aplinfo", () => ({
|
||||||
|
data: [
|
||||||
|
{ template: "debian-13-standard_13.1-2_amd64.tar.zst" },
|
||||||
|
{ template: "debian-13-standard_13.6-1_amd64.tar.zst" },
|
||||||
|
{ template: "ubuntu-24.04-standard_24.04-2_amd64.tar.zst" },
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
.route("POST", "/nodes/pve01/aplinfo", () => ({ data: UPID("download") }))
|
||||||
|
.route("GET", /\/tasks\/[^/]+\/status$/, () => ({ data: { status: "stopped", exitstatus: "OK" } }))
|
||||||
|
.route("POST", "/nodes/pve01/lxc", () => ({ data: UPID("vzcreate") }))
|
||||||
|
.route("POST", /^\/nodes\/pve01\/lxc\/\d+\/status\/start$/, () => ({ data: UPID("vzstart") }))
|
||||||
|
.route("GET", /^\/nodes\/pve01\/lxc\/\d+\/interfaces$/, () => ({
|
||||||
|
data: [
|
||||||
|
{ name: "lo", inet: "127.0.0.1/8" },
|
||||||
|
{
|
||||||
|
name: "eth0",
|
||||||
|
...(++interfaceAbfragen > (o.dhcpNachAbfragen ?? 0) ? { inet: "10.0.10.77/24" } : {}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
const client = new ProxmoxClient(
|
||||||
|
{
|
||||||
|
url: mock.url,
|
||||||
|
tokenId: MOCK_TOKEN_ID,
|
||||||
|
tokenGeheimnis: MOCK_TOKEN_GEHEIMNIS,
|
||||||
|
tlsPruefen: false,
|
||||||
|
fingerabdruck: mock.fingerabdruck,
|
||||||
|
},
|
||||||
|
{ trocken: o.trocken },
|
||||||
|
);
|
||||||
|
aufraeumen.push(async () => {
|
||||||
|
await client.schliessen();
|
||||||
|
await mock.stoppen();
|
||||||
|
});
|
||||||
|
return { mock, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eingabe(
|
||||||
|
client: ProxmoxClient,
|
||||||
|
teil: Record<string, unknown> = {},
|
||||||
|
ssh: NodeBefehl | null = null,
|
||||||
|
) {
|
||||||
|
const katalog = await katalogLaden("catalog");
|
||||||
|
const anfrage = AusrollAnfrage.parse({
|
||||||
|
typ: "lxc",
|
||||||
|
server: "rz1",
|
||||||
|
distribution: "debian-13",
|
||||||
|
name: "ct01",
|
||||||
|
storage: "local-zfs",
|
||||||
|
diskGb: 8,
|
||||||
|
netz: { bridge: "vmbr0", modus: "dhcp" },
|
||||||
|
zugang: { benutzer: "florian", sshSchluessel: [SCHLUESSEL] },
|
||||||
|
profile: ["docker"],
|
||||||
|
...teil,
|
||||||
|
});
|
||||||
|
const distribution = katalog.distributionen.find((d) => d.eintrag.id === anfrage.distribution)!.eintrag;
|
||||||
|
const aufgeloest = profileAufloesen(
|
||||||
|
anfrage.profile,
|
||||||
|
new Map(katalog.profile.map((p) => [p.eintrag.id, p.eintrag])),
|
||||||
|
"debian",
|
||||||
|
);
|
||||||
|
if (!aufgeloest.ok) throw new Error("Profile");
|
||||||
|
const gast: Array<{ host: string; skript: string }> = [];
|
||||||
|
return {
|
||||||
|
gast,
|
||||||
|
e: {
|
||||||
|
client,
|
||||||
|
anfrage,
|
||||||
|
distribution,
|
||||||
|
profile: aufgeloest.profile,
|
||||||
|
profilFeatures: {
|
||||||
|
nesting: aufgeloest.profile.some((p) => p.profil.lxc.features?.nesting),
|
||||||
|
keyctl: false,
|
||||||
|
},
|
||||||
|
vmidReservieren: async () => 130,
|
||||||
|
sshFuer: () => ssh,
|
||||||
|
bereitstellung: async () => BEREIT,
|
||||||
|
angelegt: async () => {},
|
||||||
|
auftragKurzId: "d-ct",
|
||||||
|
warten: async () => {},
|
||||||
|
gastSsh: async (ziel: { host: string }, skript: string, beiAusgabe: (t: string) => void) => {
|
||||||
|
gast.push({ host: ziel.host, skript });
|
||||||
|
beiAusgabe("::proxdeploy:: profil base\n::proxdeploy:: fertig\n");
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("LXC ausrollen", () => {
|
||||||
|
it("mit Node-SSH: neuestes Template laden, anlegen mit nesting (docker), DHCP-IP, pct exec mit Skript auf stdin", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ dhcpNachAbfragen: 2 });
|
||||||
|
const befehle: Array<{ befehl: string; eingabe?: string }> = [];
|
||||||
|
const ssh: NodeBefehl = async (befehl, o) => {
|
||||||
|
befehle.push({ befehl, eingabe: o?.eingabe });
|
||||||
|
o?.beiAusgabe?.("::proxdeploy:: profil base\n");
|
||||||
|
return { code: 0, stdout: "", stderr: "" };
|
||||||
|
};
|
||||||
|
const { e } = await eingabe(client, {}, ssh);
|
||||||
|
const stand: LxcAusrollStand = {};
|
||||||
|
expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
|
||||||
|
expect(stand).toMatchObject({
|
||||||
|
weg: "node",
|
||||||
|
vmid: 130,
|
||||||
|
ip: "10.0.10.77",
|
||||||
|
ostemplate: "local:vztmpl/debian-13-standard_13.6-1_amd64.tar.zst",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
Object.fromEntries(
|
||||||
|
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")!.rumpf,
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
storage: "local",
|
||||||
|
template: "debian-13-standard_13.6-1_amd64.tar.zst",
|
||||||
|
});
|
||||||
|
const anlegen = Object.fromEntries(
|
||||||
|
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf,
|
||||||
|
);
|
||||||
|
expect(anlegen).toMatchObject({
|
||||||
|
vmid: "130",
|
||||||
|
hostname: "ct01",
|
||||||
|
rootfs: "local-zfs:8",
|
||||||
|
unprivileged: "1",
|
||||||
|
features: "nesting=1",
|
||||||
|
net0: "name=eth0,bridge=vmbr0,ip=dhcp,type=veth",
|
||||||
|
ostype: "debian",
|
||||||
|
});
|
||||||
|
expect(anlegen).not.toHaveProperty("ssh-public-keys");
|
||||||
|
expect(befehle).toHaveLength(1);
|
||||||
|
expect(befehle[0]!.befehl).toBe("pct exec 130 -- bash -s");
|
||||||
|
expect(befehle[0]!.eingabe).toContain("useradd -m -s /bin/bash florian");
|
||||||
|
// Zeitsync nur in VMs.
|
||||||
|
expect(befehle[0]!.eingabe).not.toContain("chrony");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("vorhandenes neuestes Template wird nicht erneut geladen", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ templateLokal: ["debian-13-standard_13.6-1_amd64.tar.zst"] });
|
||||||
|
const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" }));
|
||||||
|
await phasenAusfuehren(lxcAusrollSchritte(e), stumm);
|
||||||
|
expect(mock.anfragen.some((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/aplinfo")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ohne Node-SSH: Bereitstellungsschlüssel an root, SSH in den Container, Schlüssel am Ende entfernen", async () => {
|
||||||
|
const { mock, client } = await aufbauen();
|
||||||
|
const { e, gast } = await eingabe(client, {
|
||||||
|
netz: { bridge: "vmbr0", modus: "statisch", ip: "10.0.20.5/24", gateway: "10.0.20.1" },
|
||||||
|
});
|
||||||
|
const stand: LxcAusrollStand = {};
|
||||||
|
expect(await phasenAusfuehren(lxcAusrollSchritte(e, stand), stumm)).toEqual({ ok: true });
|
||||||
|
expect(stand.weg).toBe("gast");
|
||||||
|
const anlegen = Object.fromEntries(
|
||||||
|
mock.anfragen.find((a) => a.methode === "POST" && a.pfad === "/nodes/pve01/lxc")!.rumpf,
|
||||||
|
);
|
||||||
|
expect(anlegen["ssh-public-keys"]).toBe(BEREIT.oeffentlich);
|
||||||
|
expect(anlegen["net0"]).toBe("name=eth0,bridge=vmbr0,ip=10.0.20.5/24,gw=10.0.20.1,type=veth");
|
||||||
|
expect(gast[0]!.host).toBe("10.0.20.5");
|
||||||
|
expect(
|
||||||
|
gast[0]!.skript.trimEnd().endsWith("sed -i '/proxdeploy-bereitstellung/d' /root/.ssh/authorized_keys"),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keyctl ohne Node-SSH wird vor dem Anlegen abgelehnt", async () => {
|
||||||
|
const { mock, client } = await aufbauen();
|
||||||
|
const { e } = await eingabe(client, { features: { nesting: true, keyctl: true } });
|
||||||
|
const ergebnis = await phasenAusfuehren(lxcAusrollSchritte(e), stumm);
|
||||||
|
expect(ergebnis.ok || ergebnis.fehler.ursache).toMatch(/keyctl/);
|
||||||
|
expect(mock.anfragen.some((a) => a.pfad === "/nodes/pve01/lxc")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Netzkonfiguration mit VLAN", () => {
|
||||||
|
expect(lxcNetz({ bridge: "vmbr1", vlan: 30, modus: "dhcp", dns: [] })).toBe(
|
||||||
|
"name=eth0,bridge=vmbr1,tag=30,ip=dhcp,type=veth",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Trockenlauf: Plan ohne Schreibzugriffe", async () => {
|
||||||
|
const { mock, client } = await aufbauen({ trocken: true });
|
||||||
|
const { e } = await eingabe(client, {}, async () => ({ code: 0, stdout: "", stderr: "" }));
|
||||||
|
expect(await phasenAusfuehren(lxcAusrollSchritte(e), stumm)).toEqual({ ok: true });
|
||||||
|
expect(mock.anfragen.filter((a) => a.methode !== "GET")).toEqual([]);
|
||||||
|
expect(client.geplant.map((a) => `${a.methode} ${a.pfad}`)).toEqual([
|
||||||
|
"POST /nodes/pve01/aplinfo",
|
||||||
|
"POST /nodes/pve01/lxc",
|
||||||
|
"POST /nodes/pve01/lxc/130/status/start",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user