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:
@@ -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). */
|
||||
export type NodeBefehl = (
|
||||
befehl: string,
|
||||
optionen?: { eingabe?: string; zeitlimitMs?: number },
|
||||
optionen?: { eingabe?: string; zeitlimitMs?: number; beiAusgabe?: (text: string) => void },
|
||||
) => Promise<{ code: number | null; stdout: string; stderr: string }>;
|
||||
|
||||
export type TemplateBauEingabe = {
|
||||
|
||||
@@ -369,7 +369,7 @@ export function vmAusrollSchritte(e: VmAusrollEingabe, stand: VmAusrollStand = {
|
||||
const { skript } = profileRendern(
|
||||
e.profile,
|
||||
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({
|
||||
name: a.zugang.benutzer,
|
||||
|
||||
@@ -147,6 +147,8 @@ KbdInteractiveAuthentication ${h.passwortAnmeldung ? "yes" : "no"}
|
||||
PermitRootLogin ${h.rootAnmeldung ? "prohibit-password" : "no"}
|
||||
PROXDEPLOY_SSH
|
||||
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
|
||||
systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || true
|
||||
fi
|
||||
|
||||
@@ -56,7 +56,10 @@ export type Distribution = z.infer<typeof DistributionSchema>;
|
||||
|
||||
export const VariableSchema = z
|
||||
.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),
|
||||
type: z.enum(["string", "number", "boolean", "select"]).default("string"),
|
||||
required: z.boolean().default(false),
|
||||
|
||||
@@ -14,17 +14,26 @@ export const MARKE = "::proxdeploy::";
|
||||
|
||||
export type RenderKontext = {
|
||||
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 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;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -87,7 +96,7 @@ export function profileRendern(
|
||||
};
|
||||
const teile: string[] = [];
|
||||
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);
|
||||
if ("ssh_harden" in schritt) zumSchluss.push(text);
|
||||
else teile.push(text);
|
||||
|
||||
@@ -40,7 +40,13 @@ export function hostSchluesselFingerabdruck(schluessel: Buffer): string {
|
||||
export function sshAusfuehren(
|
||||
ziel: SshZiel,
|
||||
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> {
|
||||
if (!ziel.hostSchluessel && !optionen.erkunden) {
|
||||
return Promise.reject(
|
||||
@@ -95,8 +101,16 @@ export function sshAusfuehren(
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let code: number | null = null;
|
||||
kanal.on("data", (teil: Buffer) => (stdout += teil.toString("utf8")));
|
||||
kanal.stderr.on("data", (teil: Buffer) => (stderr += teil.toString("utf8")));
|
||||
kanal.on("data", (teil: Buffer) => {
|
||||
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("close", () => ende(null, { code, stdout, stderr, hostSchluessel: gesehen }));
|
||||
if (optionen.eingabe !== undefined) kanal.end(optionen.eingabe);
|
||||
|
||||
Reference in New Issue
Block a user