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:
2026-10-12 13:19:13 +02:00
co-authored by Claude Opus 5.5
parent 9310164bc2
commit d25ec54ef9
18 changed files with 893 additions and 67 deletions
+75 -23
View File
@@ -1,6 +1,8 @@
import type { Server } from "@/generated/prisma/client";
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 type { PhasenSchritt } from "@/engine/ablauf/ablauf";
import type { Distribution, Profil } from "@/engine/katalog/schema";
import { profileAufloesen, type AufgeloestesProfil } from "@/engine/profile/aufloesen";
import { ProxmoxClient, type Aufruf } from "@/engine/proxmox/client";
@@ -11,6 +13,7 @@ import { AnwendungsFehler } from "@/server/fehler";
import { auftragAnlegen } from "@/server/services/auftraege";
import { serverHolen, verbindungAus } from "@/server/services/server";
import { nodeBefehlFuer } from "@/server/services/ssh-zugang";
import { bereitstellungsschluessel } from "@/server/services/bereitstellung";
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"}.`,
);
}
if (anfrage.typ === "lxc") {
throw new AnwendungsFehler(
400,
"nicht_umgesetzt",
"LXC-Container folgen mit #009.",
"Vorerst als VM ausrollen.",
);
}
const verfuegbar = new Map(
(await prisma.profil.findMany({ where: { aktiv: true, entferntAm: null } })).map((p) => [
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 = {
kopf: string;
phasen: Array<{
@@ -138,25 +193,17 @@ export async function ausrollenPlanen(roh: unknown): Promise<Plan> {
if (angaben?.phase === "provisionierung" && nachricht.startsWith("#!/bin/bash")) skript = nachricht;
},
};
const stand: VmAusrollStand = {};
const stand: AusrollStand = {};
try {
const ergebnis = await phasenAusfuehren(
vmAusrollSchritte(
ausrollSchritte(
v,
{
client,
anfrage: v.anfrage,
distribution: v.distribution,
profile: v.profile,
vmidReservieren: (belegt, wunsch) =>
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.gastVmidVon, wunsch }),
templateBau: v.anfrage.templateBauen
? {
sshFuer: (knoten) => nodeBefehlFuer(v.server, knoten),
auftragKurzId: "dry-run",
vmidReservieren: (belegt) =>
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
}
: undefined,
templateVmidReservieren: (belegt) =>
vmidVorschlagen({ serverId: v.server.id, belegt, von: v.server.templateVmidVon }),
angelegt: async () => {},
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 [
`Server ${v.server.name}`,
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)
.join(" · ");