changed naming and order of folders and files to represent deployment order
Deploy Proxmox Infra / Pulumi Preview (push) Has been skipped
Deploy Proxmox Infra / Pulumi Deploy (push) Failing after 50s
Deploy k8s Bootstrap / Pulumi Preview (push) Has been skipped
Deploy k8s Bootstrap / Bootstrap k3s Cluster (push) Failing after 7s
Deploy k8s Infra / Pulumi Preview (push) Has been skipped
Deploy k8s Infra / Pulumi Deploy (push) Failing after 7s

This commit is contained in:
2026-06-01 20:03:51 +02:00
parent 54b97fadeb
commit 5de2a16b9b
20 changed files with 0 additions and 0 deletions
+87
View File
@@ -0,0 +1,87 @@
# k8s-bootstrap
Bootstraps a k3s cluster on the 5 Proxmox VMs created by `proxmox-infra`. Starts VMs via the Proxmox REST API, then provisions k3s over SSH using `@pulumi/command` remote.Command.
## How it works
1. Starts all 5 VMs via `POST /api2/json/nodes/{node}/qemu/{vmid}/status/start`
2. Waits for port 22 to open on each VM (bash `/dev/tcp`)
3. Installs k3s on `k3s-master-1` with `--cluster-init --tls-san <master1Ip>`
4. Joins `k3s-master-2` and `k3s-master-3` as embedded etcd nodes
5. Joins `k3s-worker-1` and `k3s-worker-2` as agent nodes
6. Reads `/etc/rancher/k3s/k3s.yaml` from master-1 via SSH, patches server URL, exports as secret stack output
VM IDs and the CI runner SSH private key are read automatically from the `proxmox-infra` stack output via `StackReference` — no manual setup needed for those.
## Required Pulumi config
Run in this directory after `pulumi stack init dev`:
```bash
# Pre-shared k3s token — any strong random string
pulumi config set --secret k3sToken "$(openssl rand -hex 32)"
# Node IPs — static DHCP leases from the router (not secrets)
pulumi config set master1Ip "192.168.1.x"
pulumi config set master2Ip "192.168.1.x"
pulumi config set master3Ip "192.168.1.x"
pulumi config set worker1Ip "192.168.1.x"
pulumi config set worker2Ip "192.168.1.x"
```
Proxmox credentials (`pve1Endpoint`, `pve1ApiToken`, `pve2Endpoint`, `pve2ApiToken`) are read automatically from the `proxmox-infra` stack outputs via StackReference — do **not** set them here.
## Deployment order
`proxmox-infra` must be deployed **before** k8s-bootstrap. The proxmox-infra stack exports the Proxmox credentials and VM IDs that k8s-bootstrap reads via StackReference. If proxmox-infra hasn't been re-deployed after its latest code changes, those outputs won't exist and k8s-bootstrap will fail.
In CI, both workflows run as previews on PRs (no deploy). Trigger `workflow_dispatch` on `deploy-proxmox-infra` first, then run k8s-bootstrap.
After setting config, re-encode `Pulumi.dev.yaml` and update the Gitea secret `K8S_BOOTSTRAP_DEV_YAML`:
```bash
base64 -w 0 Pulumi.dev.yaml
# Copy output → Gitea → Settings → Actions → Secrets → K8S_BOOTSTRAP_DEV_YAML
```
## Prerequisites
`proxmox-infra` must be deployed first. On first `pulumi up`, it generates an ed25519 SSH keypair via `@pulumi/tls`, writes the public key into every VM's cloud-init, and exports the private key as a secret stack output. VMs must be re-provisioned (or the public key manually added to `~/.ssh/authorized_keys`) before k8s-bootstrap can SSH in.
## After first run
The `kubeconfig` stack output must be propagated to `k8s-infra` and `k8s-apps`:
```bash
KUBECONFIG=$(pulumi stack output kubeconfig --show-secrets)
cd ../k8s-infra && pulumi config set --secret kubeconfig "$KUBECONFIG"
cd ../k8s-apps && pulumi config set --secret kubeconfig "$KUBECONFIG"
```
Then re-encode each `Pulumi.dev.yaml` and update the corresponding Gitea secrets
(`K8S_INFRA_DEV_YAML`, `K8S_APPS_DEV_YAML`):
```bash
base64 -w 0 k8s-infra/Pulumi.dev.yaml
base64 -w 0 k8s-apps/Pulumi.dev.yaml
```
## k8s-infra config changes (democratic-csi → NFS CSI)
The `k8s-infra` stack no longer needs `truenasApiKey` or `truenasDataset`.
Replace them with:
```bash
cd ../k8s-infra
pulumi config set truenasHost "192.168.1.x"
pulumi config set truenasNfsPath "/mnt/tank/k8s"
# Remove old keys if present:
pulumi config rm truenasApiKey
pulumi config rm truenasDataset
```
## TrueNAS one-time setup (before deploying k8s-infra)
1. Create a dataset: `tank/k8s`
2. Add an NFS share for that dataset
3. In Network → Allowed Networks, permit `192.168.1.0/24`
4. No API key required — the NFS CSI driver connects directly via NFS protocol
+10
View File
@@ -0,0 +1,10 @@
name: k8s-bootstrap
description: Bootstrap k3s cluster on Proxmox VMs via QEMU guest exec
runtime:
name: nodejs
options:
packagemanager: npm
config:
pulumi:tags:
value:
pulumi:template: typescript
+235
View File
@@ -0,0 +1,235 @@
import * as pulumi from "@pulumi/pulumi";
import * as command from "@pulumi/command";
const config = new pulumi.Config();
//fetch credentials from proxmox-infra
const infraRef = new pulumi.StackReference(
`${pulumi.getOrganization()}/proxmox-infra/dev`,
);
// Proxmox API credentials — same as proxmox-infra stack
const pve1Endpoint = infraRef.requireOutput(
"pve1Endpoint",
) as pulumi.Output<string>;
const pve1ApiToken = infraRef.requireOutput(
"pve1ApiToken",
) as pulumi.Output<string>;
const pve2Endpoint = infraRef.requireOutput(
"pve2Endpoint",
) as pulumi.Output<string>;
const pve2ApiToken = infraRef.requireOutput(
"pve2ApiToken",
) as pulumi.Output<string>;
// Node IPs — static DHCP leases set in the router
const master1Ip = infraRef.requireOutput("master1Ip");
const master2Ip = infraRef.requireOutput("master2Ip");
const master3Ip = infraRef.requireOutput("master3Ip");
const worker1Ip = infraRef.requireOutput("worker1Ip");
const worker2Ip = infraRef.requireOutput("worker2Ip");
// Pre-shared k3s cluster token
const k3sToken = config.requireSecret("k3sToken");
// VM IDs and CI runner SSH key — read from proxmox-infra stack outputs
const vmIdsOutput = infraRef.requireOutput("vmIds") as pulumi.Output<
Record<string, number>
>;
const ciRunnerPrivateKey = infraRef.requireOutput(
"ciRunnerPrivateKey",
) as pulumi.Output<string>;
const master1VmId = vmIdsOutput.apply((ids) => String(ids.master1));
const master2VmId = vmIdsOutput.apply((ids) => String(ids.master2));
const master3VmId = vmIdsOutput.apply((ids) => String(ids.master3));
const worker1VmId = vmIdsOutput.apply((ids) => String(ids.worker1));
const worker2VmId = vmIdsOutput.apply((ids) => String(ids.worker2));
// SSH connection helper
function conn(
ip: pulumi.Input<string>,
): command.types.input.remote.ConnectionArgs {
return { host: ip, user: "ubuntu", privateKey: ciRunnerPrivateKey };
}
// ---------------------------------------------------------------------------
// Step 1: Start all VMs (fire-and-forget — VMs are owned by proxmox-infra)
// triggers: vmId — forces re-run when VMs are deleted and recreated
// ---------------------------------------------------------------------------
const startMaster1 = new command.local.Command("start-master-1", {
create: pulumi.interpolate`curl -sf -k -X POST -H "Authorization: PVEAPIToken=${pve1ApiToken}" "${pve1Endpoint}/api2/json/nodes/pve/qemu/${master1VmId}/status/start" 2>/dev/null || true`,
triggers: [master1VmId],
interpreter: ["/bin/bash", "-c"],
});
const startMaster2 = new command.local.Command("start-master-2", {
create: pulumi.interpolate`curl -sf -k -X POST -H "Authorization: PVEAPIToken=${pve1ApiToken}" "${pve1Endpoint}/api2/json/nodes/pve/qemu/${master2VmId}/status/start" 2>/dev/null || true`,
triggers: [master2VmId],
interpreter: ["/bin/bash", "-c"],
});
const startWorker1 = new command.local.Command("start-worker-1", {
create: pulumi.interpolate`curl -sf -k -X POST -H "Authorization: PVEAPIToken=${pve1ApiToken}" "${pve1Endpoint}/api2/json/nodes/pve/qemu/${worker1VmId}/status/start" 2>/dev/null || true`,
triggers: [worker1VmId],
interpreter: ["/bin/bash", "-c"],
});
const startMaster3 = new command.local.Command("start-master-3", {
create: pulumi.interpolate`curl -sf -k -X POST -H "Authorization: PVEAPIToken=${pve2ApiToken}" "${pve2Endpoint}/api2/json/nodes/pve-bckp/qemu/${master3VmId}/status/start" 2>/dev/null || true`,
triggers: [master3VmId],
interpreter: ["/bin/bash", "-c"],
});
const startWorker2 = new command.local.Command("start-worker-2", {
create: pulumi.interpolate`curl -sf -k -X POST -H "Authorization: PVEAPIToken=${pve2ApiToken}" "${pve2Endpoint}/api2/json/nodes/pve-bckp/qemu/${worker2VmId}/status/start" 2>/dev/null || true`,
triggers: [worker2VmId],
interpreter: ["/bin/bash", "-c"],
});
const allStarts = [
startMaster1,
startMaster2,
startMaster3,
startWorker1,
startWorker2,
];
// ---------------------------------------------------------------------------
// Step 2: Wait for SSH on master-1, install k3s
// ---------------------------------------------------------------------------
const waitMaster1Ssh = new command.local.Command(
"wait-ssh-master-1",
{
create: pulumi.interpolate`for i in $(seq 1 60); do (timeout 5 bash -c "echo > /dev/tcp/${master1Ip}/22") 2>/dev/null && exit 0; sleep 5; done; exit 1`,
triggers: [master1VmId],
interpreter: ["/bin/bash", "-c"],
},
{ dependsOn: allStarts },
);
const installMaster1 = new command.remote.Command(
"install-k3s-master-1",
{
connection: conn(master1Ip),
create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_TOKEN='${k3sToken}' sh -s - server --cluster-init --tls-san ${master1Ip} --node-name k3s-master-1`,
triggers: [master1VmId],
},
{ dependsOn: [waitMaster1Ssh] },
);
const waitK3sMaster1Ready = new command.remote.Command(
"wait-k3s-master-1-ready",
{
connection: conn(master1Ip),
create: `for i in $(seq 1 60); do sudo kubectl --kubeconfig /etc/rancher/k3s/k3s.yaml get node k3s-master-1 --no-headers 2>/dev/null | grep -q " Ready" && exit 0; sleep 10; done; exit 1`,
triggers: [master1VmId],
},
{ dependsOn: [installMaster1] },
);
// ---------------------------------------------------------------------------
// Step 3: Wait for SSH on remaining masters, join cluster
// ---------------------------------------------------------------------------
const waitMaster2Ssh = new command.local.Command(
"wait-ssh-master-2",
{
create: pulumi.interpolate`for i in $(seq 1 60); do (timeout 5 bash -c "echo > /dev/tcp/${master2Ip}/22") 2>/dev/null && exit 0; sleep 5; done; exit 1`,
triggers: [master2VmId],
interpreter: ["/bin/bash", "-c"],
},
{ dependsOn: [waitK3sMaster1Ready] },
);
const waitMaster3Ssh = new command.local.Command(
"wait-ssh-master-3",
{
create: pulumi.interpolate`for i in $(seq 1 60); do (timeout 5 bash -c "echo > /dev/tcp/${master3Ip}/22") 2>/dev/null && exit 0; sleep 5; done; exit 1`,
triggers: [master3VmId],
interpreter: ["/bin/bash", "-c"],
},
{ dependsOn: [waitK3sMaster1Ready] },
);
const joinMaster2 = new command.remote.Command(
"join-k3s-master-2",
{
connection: conn(master2Ip),
create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_TOKEN='${k3sToken}' sh -s - server --server https://${master1Ip}:6443 --node-name k3s-master-2`,
triggers: [master2VmId],
},
{ dependsOn: [waitMaster2Ssh] },
);
const joinMaster3 = new command.remote.Command(
"join-k3s-master-3",
{
connection: conn(master3Ip),
create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_TOKEN='${k3sToken}' sh -s - server --server https://${master1Ip}:6443 --node-name k3s-master-3`,
triggers: [master3VmId],
},
{ dependsOn: [waitMaster3Ssh, joinMaster2] },
);
// ---------------------------------------------------------------------------
// Step 4: Join worker nodes
// ---------------------------------------------------------------------------
const waitWorker1Ssh = new command.local.Command(
"wait-ssh-worker-1",
{
create: pulumi.interpolate`for i in $(seq 1 60); do (timeout 5 bash -c "echo > /dev/tcp/${worker1Ip}/22") 2>/dev/null && exit 0; sleep 5; done; exit 1`,
triggers: [worker1VmId],
interpreter: ["/bin/bash", "-c"],
},
{ dependsOn: [joinMaster3] },
);
const waitWorker2Ssh = new command.local.Command(
"wait-ssh-worker-2",
{
create: pulumi.interpolate`for i in $(seq 1 60); do (timeout 5 bash -c "echo > /dev/tcp/${worker2Ip}/22") 2>/dev/null && exit 0; sleep 5; done; exit 1`,
triggers: [worker2VmId],
interpreter: ["/bin/bash", "-c"],
},
{ dependsOn: [joinMaster3] },
);
const joinWorker1 = new command.remote.Command(
"join-k3s-worker-1",
{
connection: conn(worker1Ip),
create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_URL=https://${master1Ip}:6443 K3S_TOKEN='${k3sToken}' sh -s - --node-name k3s-worker-1`,
triggers: [worker1VmId],
},
{ dependsOn: [waitWorker1Ssh] },
);
const joinWorker2 = new command.remote.Command(
"join-k3s-worker-2",
{
connection: conn(worker2Ip),
create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_URL=https://${master1Ip}:6443 K3S_TOKEN='${k3sToken}' sh -s - --node-name k3s-worker-2`,
triggers: [worker2VmId],
},
{ dependsOn: [waitWorker2Ssh] },
);
// ---------------------------------------------------------------------------
// Step 5: Read kubeconfig from master-1, patch server URL for external access
// ---------------------------------------------------------------------------
const getKubeconfig = new command.remote.Command(
"get-kubeconfig",
{
connection: conn(master1Ip),
create: `sudo cat /etc/rancher/k3s/k3s.yaml`,
triggers: [master1VmId],
},
{ dependsOn: [joinWorker1, joinWorker2] },
);
export const kubeconfig = pulumi.secret(
pulumi
.all([getKubeconfig.stdout, master1Ip])
.apply(([kc, ip]) => kc.replaceAll("127.0.0.1", ip as string).trim()),
);
+2670
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "k8s-bootstrap",
"main": "index.ts",
"devDependencies": {
"@types/node": "^18",
"typescript": "^5.0.0"
},
"dependencies": {
"@pulumi/command": "^0.11.0",
"@pulumi/pulumi": "^3.113.0"
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"outDir": "bin",
"target": "es2024",
"module": "nodenext",
"moduleResolution": "nodenext",
"sourceMap": true,
"experimentalDecorators": true,
"pretty": true,
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"forceConsistentCasingInFileNames": true
},
"files": [
"index.ts"
]
}