Compare commits

...
Author SHA1 Message Date
kasun 19bf92d6bc docs(k8s-bootstrap): document control-plane VIP and pfSense HAProxy setup
Deploy k8s Bootstrap / Bootstrap k3s Cluster (pull_request) Skipped
Deploy k8s Bootstrap / Pulumi Preview (pull_request) Successful in 40s
The VIP and HAProxy frontend/backend aren't exposed by the pfsense
provider, so they must be created by hand before this stack is deployed.
2026-10-02 23:52:32 +02:00
kasun afac1f338f feature(k8s-bootstrap): join nodes and export kubeconfig via control-plane VIP
Every join and the exported kubeconfig previously pointed at master1Ip,
so losing master-1 cut off API access even with etcd quorum intact. Use
controlPlaneVipAddress (a pfSense IP Alias fronted by HAProxy across all
three masters) instead, add it as a TLS SAN on master-1, and wait for
the VIP's /healthz before any other node joins.

The VIP is set up manually on pfSense rather than via a second
kube-vip/ARP VIP: API access is too critical to depend on the same
failover mechanism that serves LoadBalancer Services.

Also strips trailing whitespace from the Longhorn disk mount script.
2026-10-02 23:52:32 +02:00
kasun 2f57f1822f Merge pull request 'Enhancement/consolidate proxmox node config' (#32) from enhancement/consolidate-proxmox-node-config into main
Reviewed-on: #32
2026-08-05 16:39:09 +02:00
kasun 07c4678a6d docs(proxmox-infra): removed inline comments
Deploy k8s Bootstrap / Pulumi Preview (pull_request) Successful in 48s
Deploy k8s Bootstrap / Bootstrap k3s Cluster (pull_request) Skipped
Deploy Proxmox Infra / Pulumi Deploy (pull_request) Skipped
Deploy Proxmox Infra / Pulumi Preview (pull_request) Successful in 1m22s
2026-08-05 16:32:07 +02:00
kasun fb1f8db36b enhancenment(proxmox-infra): added ssh pub keys of the other 2 nodes to cloud init vm creation. added the nodes manually since already created.
Deploy k8s Bootstrap / Pulumi Preview (pull_request) Successful in 39s
Deploy k8s Bootstrap / Bootstrap k3s Cluster (pull_request) Skipped
Deploy Proxmox Infra / Pulumi Deploy (pull_request) Skipped
Deploy Proxmox Infra / Pulumi Preview (pull_request) Successful in 1m16s
2026-08-05 16:31:38 +02:00
kasun 0ae688ac66 enhancement(proxmox-infra): consolidate per-host config into a single table
Deploy Proxmox Infra / Pulumi Preview (pull_request) Successful in 53s
Deploy Proxmox Infra / Pulumi Deploy (pull_request) Skipped
Deploy k8s Bootstrap / Pulumi Preview (pull_request) Successful in 40s
Deploy k8s Bootstrap / Bootstrap k3s Cluster (pull_request) Skipped
Provider, image download, and template were three separate consts per
physical node with matching lookups scattered through nodeConfigs -
easy to update one and miss another when adding a node. Folded them
into one pveNodes pipeline (seed data -> provider -> image -> template)
and had nodeConfigs reference a host row instead of copying its fields.

Also folds in two fixes discovered while bringing up worker-3, now
inseparable from the restructuring above:
- disable the default physical CD-ROM passthrough (cdrom: fileId "none")
  that failed VM start on nodes without an optical drive
- shorten the qemu-agent timeout from the 15m default so refresh/read
  fails fast instead of hanging
2026-07-30 02:55:01 +02:00
kasun b7d5a5c5cd bug(k8s-bootstrap): install and enable qemu-guest-agent on all nodes
qemu-guest-agent isn't present in the stock Ubuntu Noble cloud image,
which was causing pulumi refresh on proxmox-infra to block up to 15m
per VM waiting for agent data that never arrives.
2026-07-30 02:54:56 +02:00
3 changed files with 249 additions and 212 deletions

No files matched your search

+128 -190
View File
@@ -25,33 +25,121 @@ const worker1Ip = config.requireSecret("worker1Ip");
const worker2Ip = config.requireSecret("worker2Ip"); const worker2Ip = config.requireSecret("worker2Ip");
const worker3Ip = config.requireSecret("worker3Ip"); const worker3Ip = config.requireSecret("worker3Ip");
// ---------------------------------------------------------------------------
// VM templates — one per node, cloned from the downloaded cloud image.
// Templates are not started and have no cloud-init config; that is applied
// per-clone so each node gets its own hostname (derived from VM name).
// ---------------------------------------------------------------------------
const templateSettings = {
template: true,
started: false,
stopOnDestroy: true,
scsiHardware: "virtio-scsi-pci",
cdrom: { fileId: "none" },
cpu: {
cores: 2,
sockets: 1,
type: "host",
numa: true,
},
memory: {
dedicated: 2048,
floating: 0,
},
networkDevices: [{ bridge: "vmbr0", model: "virtio" }],
serialDevices: [{}],
vga: { type: "serial0" },
agent: { enabled: true, timeout: "30s" },
};
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Providers — one per standalone Proxmox machine // Providers — one per standalone Proxmox machine
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const pveProvider = new proxmox.Provider("pve", { interface PveNodeConfig {
nodeName: string;
hostLabel: string;
isoStore: string;
dataStore: string;
endpoint: pulumi.Output<string>;
apiToken: pulumi.Output<string>;
}
const pveNodeSeeds: PveNodeConfig[] = [
{
nodeName: "pve",
hostLabel: "pve-b550",
isoStore: "pve-local-ext1",
dataStore: "local-lvm",
endpoint: pve1Endpoint, endpoint: pve1Endpoint,
apiToken: pve1ApiToken, apiToken: pve1ApiToken,
insecure: true, },
}); {
nodeName: "pve-bckp",
const pveBckpProvider = new proxmox.Provider("pve-bckp", { hostLabel: "pve-optiplex",
isoStore: "local",
dataStore: "local",
endpoint: pve2Endpoint, endpoint: pve2Endpoint,
apiToken: pve2ApiToken, apiToken: pve2ApiToken,
insecure: true, },
}); {
nodeName: "pve",
const pveEsprimoProvider = new proxmox.Provider("pve-esprimo", { hostLabel: "pve-esprimo",
isoStore: "local",
dataStore: "local-lvm",
endpoint: pve3Endpoint, endpoint: pve3Endpoint,
apiToken: pve3ApiToken, apiToken: pve3ApiToken,
insecure: true, },
}); ];
const pveNodes = { const pveNodes = pveNodeSeeds
"pve-b550": { nodeName: "pve", isoStore: "pve-local-ext1", dataStore: "local-lvm" }, .map((n) => ({
"pve-optiplex": { nodeName: "pve-bckp", isoStore: "local", dataStore: "local" }, ...n,
"pve-esprimo": { nodeName: "pve", isoStore: "local", dataStore: "local-lvm" }, provider: new proxmox.Provider(n.hostLabel, {
} as const; endpoint: n.endpoint,
apiToken: n.apiToken,
insecure: true,
}),
}))
.map((n) => ({
...n,
ubuntuImagePve: new proxmox.download.File(
`ubuntu-noble-${n.hostLabel}`,
{
nodeName: n.nodeName,
datastoreId: n.isoStore,
contentType: "import",
fileName: "noble-server-cloudimg-amd64.qcow2",
url: "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img",
overwrite: false,
overwriteUnmanaged: true,
},
{ provider: n.provider },
),
}))
.map((n) => ({
...n,
pveTemplate: new proxmox.VmLegacy(
`k3s-template-${n.hostLabel}`,
{
...templateSettings,
nodeName: n.nodeName,
name: "k3s-ubuntu-noble-template",
disks: [
{
interface: "scsi0",
datastoreId: n.dataStore,
importFrom: pulumi.interpolate`${n.ubuntuImagePve.datastoreId}:import/${n.ubuntuImagePve.fileName}`,
size: 10,
ssd: true,
discard: "on",
},
],
},
{ provider: n.provider, ignoreChanges: ["disks"] },
),
}));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Providers — PfSense // Providers — PfSense
@@ -73,215 +161,62 @@ const ciRunnerKey = new tls.PrivateKey("ci-runner-key", {
algorithm: "ED25519", algorithm: "ED25519",
}); });
// ---------------------------------------------------------------------------
// Download Ubuntu Noble cloud image to each node's ISO storage
// ---------------------------------------------------------------------------
const ubuntuNobleUrl =
"https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img";
const ubuntuImagePve = new proxmox.download.File(
"ubuntu-noble-pve",
{
nodeName: pveNodes["pve-b550"].nodeName,
datastoreId: pveNodes["pve-b550"].isoStore,
contentType: "import",
fileName: "noble-server-cloudimg-amd64.qcow2",
url: ubuntuNobleUrl,
overwrite: false,
overwriteUnmanaged: true,
},
{ provider: pveProvider },
);
const ubuntuImagePveBckp = new proxmox.download.File(
"ubuntu-noble-pve-bckp",
{
nodeName: pveNodes["pve-optiplex"].nodeName,
datastoreId: pveNodes["pve-optiplex"].isoStore,
contentType: "import",
fileName: "noble-server-cloudimg-amd64.qcow2",
url: ubuntuNobleUrl,
overwrite: false,
overwriteUnmanaged: true,
},
{ provider: pveBckpProvider },
);
const ubuntuImagePveEsprimo = new proxmox.download.File(
"ubuntu-noble-pve-esprimo",
{
nodeName: pveNodes["pve-esprimo"].nodeName,
datastoreId: pveNodes["pve-esprimo"].isoStore,
contentType: "import",
fileName: "noble-server-cloudimg-amd64.qcow2",
url: ubuntuNobleUrl,
overwrite: false,
overwriteUnmanaged: true,
},
{ provider: pveEsprimoProvider },
);
// ---------------------------------------------------------------------------
// VM templates — one per node, cloned from the downloaded cloud image.
// Templates are not started and have no cloud-init config; that is applied
// per-clone so each node gets its own hostname (derived from VM name).
// ---------------------------------------------------------------------------
const templateSettings = {
template: true,
started: false,
stopOnDestroy: true,
scsiHardware: "virtio-scsi-pci",
cpu: {
cores: 2,
sockets: 1,
type: "host",
numa: true,
},
memory: {
dedicated: 2048,
floating: 0,
},
networkDevices: [{ bridge: "vmbr0", model: "virtio" }],
serialDevices: [{}],
vga: { type: "serial0" },
agent: { enabled: true },
};
const pveTemplate = new proxmox.VmLegacy(
"k3s-template-pve",
{
...templateSettings,
nodeName: "pve",
name: "k3s-ubuntu-noble-template",
disks: [
{
interface: "scsi0",
datastoreId: pveNodes["pve-b550"].dataStore,
importFrom: pulumi.interpolate`${ubuntuImagePve.datastoreId}:import/${ubuntuImagePve.fileName}`,
size: 10,
ssd: true,
discard: "on",
},
],
},
{ provider: pveProvider, ignoreChanges: ["disks"] },
);
const pveBckpTemplate = new proxmox.VmLegacy(
"k3s-template-pve-bckp",
{
...templateSettings,
nodeName: "pve-bckp",
name: "k3s-ubuntu-noble-template",
disks: [
{
interface: "scsi0",
datastoreId: pveNodes["pve-optiplex"].dataStore,
importFrom: pulumi.interpolate`${ubuntuImagePveBckp.datastoreId}:import/${ubuntuImagePveBckp.fileName}`,
size: 10,
ssd: true,
discard: "on",
},
],
},
{ provider: pveBckpProvider, ignoreChanges: ["disks"] },
);
const pveEsprimoTemplate = new proxmox.VmLegacy(
"k3s-template-pve-esprimo",
{
...templateSettings,
nodeName: "pve",
name: "k3s-ubuntu-noble-template",
disks: [
{
interface: "scsi0",
datastoreId: pveNodes["pve-esprimo"].dataStore,
importFrom: pulumi.interpolate`${ubuntuImagePveEsprimo.datastoreId}:import/${ubuntuImagePveEsprimo.fileName}`,
size: 10,
ssd: true,
discard: "on",
},
],
},
{ provider: pveEsprimoProvider, ignoreChanges: ["disks"] },
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// k3s nodes — full clones of their respective templates // k3s nodes — full clones of their respective templates
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const k3sVmPassword = config.requireSecret("k3sVmPassword"); const k3sVmPassword = config.requireSecret("k3sVmPassword");
const sshPvePublicKey = config.requireSecret("sshPvePublicKey"); const pve1SshPublicKey = config.requireSecret("pve1SshPublicKey");
const pve2SshPublicKey = config.requireSecret("pve2SshPublicKey");
const pve3SshPublicKey = config.requireSecret("pve3SshPublicKey");
interface NodeConfig { interface NodeConfig {
name: string; name: string;
role: "master" | "worker"; role: "master" | "worker";
nodeName: string; host: (typeof pveNodes)[number]; // one row from the pveNodes chain
provider: proxmox.Provider;
template: proxmox.VmLegacy;
diskDatastore: string;
ip: pulumi.Output<string>; ip: pulumi.Output<string>;
longhornDiskSize?: number; longhornDiskSize?: number;
} }
const hostFor = (label: string) => pveNodes.find((n) => n.hostLabel === label)!;
const nodeConfigs: NodeConfig[] = [ const nodeConfigs: NodeConfig[] = [
{ {
name: "k3s-master-1", name: "k3s-master-1",
role: "master", role: "master",
nodeName: pveNodes["pve-b550"].nodeName, host: hostFor("pve-b550"),
provider: pveProvider,
template: pveTemplate,
diskDatastore: pveNodes["pve-b550"].dataStore,
ip: master1Ip, ip: master1Ip,
}, },
{ {
name: "k3s-master-2", name: "k3s-master-2",
role: "master", role: "master",
nodeName: pveNodes["pve-esprimo"].nodeName, host: hostFor("pve-esprimo"),
provider: pveEsprimoProvider,
template: pveTemplate,
diskDatastore: pveNodes["pve-esprimo"].dataStore,
ip: master2Ip, ip: master2Ip,
}, },
{ {
name: "k3s-worker-1", name: "k3s-worker-1",
role: "worker", role: "worker",
nodeName: pveNodes["pve-b550"].nodeName, host: hostFor("pve-b550"),
provider: pveProvider,
template: pveTemplate,
diskDatastore: pveNodes["pve-b550"].dataStore,
ip: worker1Ip, ip: worker1Ip,
longhornDiskSize: 50, longhornDiskSize: 50,
}, },
{ {
name: "k3s-master-3", name: "k3s-master-3",
role: "master", role: "master",
nodeName: pveNodes["pve-optiplex"].nodeName, host: hostFor("pve-optiplex"),
provider: pveBckpProvider,
template: pveBckpTemplate,
diskDatastore: pveNodes["pve-optiplex"].dataStore,
ip: master3Ip, ip: master3Ip,
}, },
{ {
name: "k3s-worker-2", name: "k3s-worker-2",
role: "worker", role: "worker",
nodeName: pveNodes["pve-optiplex"].nodeName, host: hostFor("pve-optiplex"),
provider: pveBckpProvider,
template: pveBckpTemplate,
diskDatastore: pveNodes["pve-optiplex"].dataStore,
ip: worker2Ip, ip: worker2Ip,
longhornDiskSize: 50, longhornDiskSize: 50,
}, },
{ {
name: "k3s-worker-3", name: "k3s-worker-3",
role: "worker", role: "worker",
nodeName: pveNodes["pve-esprimo"].nodeName, host: hostFor("pve-esprimo"),
provider: pveEsprimoProvider,
template: pveEsprimoTemplate,
diskDatastore: pveNodes["pve-esprimo"].dataStore,
ip: worker3Ip, ip: worker3Ip,
longhornDiskSize: 50, longhornDiskSize: 50,
}, },
@@ -292,14 +227,14 @@ const k3sVms = nodeConfigs.map(
new proxmox.VmLegacy( new proxmox.VmLegacy(
node.name, node.name,
{ {
nodeName: node.nodeName, nodeName: node.host.nodeName,
name: node.name, name: node.name,
description: "k3s " + node.role + " node — managed by Pulumi", description: "k3s " + node.role + " node — managed by Pulumi",
tags: ["k3s", node.role], tags: ["k3s", node.role],
clone: { clone: {
vmId: node.template.vmId, vmId: node.host.pveTemplate.vmId,
full: true, full: true,
datastoreId: node.diskDatastore, datastoreId: node.host.dataStore,
}, },
cpu: { cpu: {
cores: 2, cores: 2,
@@ -314,7 +249,7 @@ const k3sVms = nodeConfigs.map(
disks: [ disks: [
{ {
interface: "scsi0", interface: "scsi0",
datastoreId: node.diskDatastore, datastoreId: node.host.dataStore,
size: 35, size: 35,
ssd: true, ssd: true,
discard: "on", discard: "on",
@@ -324,7 +259,7 @@ const k3sVms = nodeConfigs.map(
: [ : [
{ {
interface: "scsi1", interface: "scsi1",
datastoreId: node.diskDatastore, datastoreId: node.host.dataStore,
size: node.longhornDiskSize, size: node.longhornDiskSize,
ssd: true, ssd: true,
discard: "on", discard: "on",
@@ -332,13 +267,15 @@ const k3sVms = nodeConfigs.map(
]), ]),
], ],
initialization: { initialization: {
datastoreId: node.diskDatastore, datastoreId: node.host.dataStore,
ipConfigs: [{ ipv4: { address: "dhcp" } }], ipConfigs: [{ ipv4: { address: "dhcp" } }],
userAccount: { userAccount: {
username: "ubuntu", username: "ubuntu",
password: k3sVmPassword, password: k3sVmPassword,
keys: [ keys: [
sshPvePublicKey.apply((k) => k.trim()), pve1SshPublicKey.apply((k) => k.trim()),
pve2SshPublicKey.apply((k) => k.trim()),
pve3SshPublicKey.apply((k) => k.trim()),
ciRunnerKey.publicKeyOpenssh.apply((k) => k.trim()), ciRunnerKey.publicKeyOpenssh.apply((k) => k.trim()),
], ],
}, },
@@ -347,12 +284,13 @@ const k3sVms = nodeConfigs.map(
scsiHardware: "virtio-scsi-pci", scsiHardware: "virtio-scsi-pci",
serialDevices: [{}], serialDevices: [{}],
vga: { type: "serial0" }, vga: { type: "serial0" },
agent: { enabled: true }, agent: { enabled: true, timeout: "30s" },
cdrom: { fileId: "none" },
started: false, started: false,
stopOnDestroy: true, stopOnDestroy: true,
}, },
{ {
provider: node.provider, provider: node.host.provider,
retainOnDelete: true, retainOnDelete: true,
ignoreChanges: ["clone", "started"], ignoreChanges: ["clone", "started"],
}, },
+21 -5
View File
@@ -7,12 +7,12 @@ Bootstraps a k3s cluster on the Proxmox VMs created by `proxmox-infra`. Starts V
## How it works ## How it works
1. Starts all VMs via `POST /api2/json/nodes/{node}/qemu/{vmid}/status/start` 1. Starts all VMs via `POST /api2/json/nodes/{node}/qemu/{vmid}/status/start`
2. Waits for port 22 to open on each VM (bash `/dev/tcp`) 2. Waits for port 22 to open on each VM (bash `/dev/tcp`), installs k3s on `k3s-master-1` with `--cluster-init --tls-san <master1Ip> --tls-san <controlPlaneVipAddress>`
3. Installs k3s on `k3s-master-1` with `--cluster-init --tls-san <master1Ip>` 3. Waits for `https://<controlPlaneVipAddress>:6443/healthz` to respond before anyone else joins, since every subsequent join uses the VIP, not `master1Ip`. The VIP itself is a pfSense IP Alias Virtual IP with HAProxy TCP-load-balancing 6443 across all three masters — set up manually, not managed by any Pulumi stack (see "pfSense one-time setup" below). This is deliberately *not* a second kube-vip/ARP-based VIP: `03-k8s-infra`'s existing kube-vip DaemonSet already handles Service/LoadBalancer VIPs (e.g. Traefik) separately, entirely inside the cluster, and API access is too critical to depend on the same ARP-failover mechanism
4. Joins `k3s-master-2` and `k3s-master-3` as embedded etcd nodes 4. Joins `k3s-master-2` and `k3s-master-3` as embedded etcd nodes via `--server https://<controlPlaneVipAddress>:6443`
5. Joins `k3s-worker-1`, `k3s-worker-2`, and `k3s-worker-3` as agent nodes 5. Joins `k3s-worker-1`, `k3s-worker-2`, and `k3s-worker-3` as agent nodes via `K3S_URL=https://<controlPlaneVipAddress>:6443`
6. Formats the workers' dedicated `scsi1` disk (ext4, if not already formatted) and mounts it at `/mnt/longhorn-extra` (via `/etc/fstab`) — this is the disk `03-k8s-infra` later registers as an extra Longhorn disk, to keep Longhorn off the root filesystem and avoid `DiskPressure` 6. Formats the workers' dedicated `scsi1` disk (ext4, if not already formatted) and mounts it at `/mnt/longhorn-extra` (via `/etc/fstab`) — this is the disk `03-k8s-infra` later registers as an extra Longhorn disk, to keep Longhorn off the root filesystem and avoid `DiskPressure`
7. Reads `/etc/rancher/k3s/k3s.yaml` from master-1 via SSH, patches the server URL, and exports it as the secret stack output `kubeconfig` 7. Reads `/etc/rancher/k3s/k3s.yaml` from master-1 via SSH, patches the server URL to `controlPlaneVipAddress`, and exports it as the secret stack output `kubeconfig`
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. 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.
@@ -29,6 +29,11 @@ pulumi config set master3Ip "192.168.1.x"
pulumi config set worker1Ip "192.168.1.x" pulumi config set worker1Ip "192.168.1.x"
pulumi config set worker2Ip "192.168.1.x" pulumi config set worker2Ip "192.168.1.x"
pulumi config set worker3Ip "192.168.1.x" pulumi config set worker3Ip "192.168.1.x"
# Control-plane VIP — floating IP for API server access, fronted by pfSense
# + HAProxy (see "pfSense one-time setup" below). Must be a free address
# outside the router's DHCP pool.
pulumi config set --secret controlPlaneVipAddress "192.168.1.147"
``` ```
Proxmox credentials (`pve1Endpoint`, `pve1ApiToken`, `pve2Endpoint`, `pve2ApiToken`, `pve3Endpoint`, `pve3ApiToken`) are read automatically from `proxmox-infra` via StackReference — do **not** set them here. Proxmox credentials (`pve1Endpoint`, `pve1ApiToken`, `pve2Endpoint`, `pve2ApiToken`, `pve3Endpoint`, `pve3ApiToken`) are read automatically from `proxmox-infra` via StackReference — do **not** set them here.
@@ -37,6 +42,17 @@ Proxmox credentials (`pve1Endpoint`, `pve1ApiToken`, `pve2Endpoint`, `pve2ApiTok
`proxmox-infra` must be deployed first. On its 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 have the public key manually added to `~/.ssh/authorized_keys`) before this stack can SSH in. `proxmox-infra` must be deployed first. On its 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 have the public key manually added to `~/.ssh/authorized_keys`) before this stack can SSH in.
## pfSense one-time setup (before deploying)
The `@pulumi/pfsense` provider used by `01-proxmox-infra` doesn't expose Virtual IP or HAProxy resources (only DHCP static mappings, DNS overrides, and firewall aliases), so this is manual, not Pulumi-managed:
1. Confirm `controlPlaneVipAddress` falls outside the DHCP pool (Services → DHCP Server → Range).
2. Firewall → Virtual IPs → Add: Type = `IP Alias`, Interface = `LAN`, Address = `<controlPlaneVipAddress>/32`.
3. System → Package Manager → install `haproxy` if not already installed.
4. Services → HAProxy → Backend: add a pool (e.g. `k3s-apiservers`), mode `TCP`, health check `TCP check` (or `ssl-hello-chk`), servers = `<master1Ip>:6443`, `<master2Ip>:6443`, `<master3Ip>:6443`.
5. Services → HAProxy → Frontend: add a frontend listening on `<controlPlaneVipAddress>:6443`, type `TCP` (not HTTP — this must stay an opaque passthrough so clients validate k3s's own TLS cert, not one terminated at pfSense), default backend = the pool from step 4.
6. Services → HAProxy → Settings: enable HAProxy, apply.
## CI/CD ## CI/CD
Workflow: `../.gitea/workflows/02-deploy-k8s-bootstrap.yaml`. Secret: `K8S_BOOTSTRAP_PULUMI_DEV_YAML`. Deploy is manual (`workflow_dispatch`) — re-running this stack restarts the bootstrap sequence against live VMs. Workflow: `../.gitea/workflows/02-deploy-k8s-bootstrap.yaml`. Secret: `K8S_BOOTSTRAP_PULUMI_DEV_YAML`. Deploy is manual (`workflow_dispatch`) — re-running this stack restarts the bootstrap sequence against live VMs.
+100 -17
View File
@@ -3,12 +3,10 @@ import * as command from "@pulumi/command";
const config = new pulumi.Config(); const config = new pulumi.Config();
//fetch credentials from proxmox-infra
const infraRef = new pulumi.StackReference( const infraRef = new pulumi.StackReference(
`${pulumi.getOrganization()}/proxmox-infra/dev`, `${pulumi.getOrganization()}/proxmox-infra/dev`,
); );
// Proxmox API credentials — same as proxmox-infra stack
const pve1Endpoint = infraRef.requireOutput( const pve1Endpoint = infraRef.requireOutput(
"pve1Endpoint", "pve1Endpoint",
) as pulumi.Output<string>; ) as pulumi.Output<string>;
@@ -39,6 +37,13 @@ const worker3Ip = infraRef.requireOutput("worker3Ip");
// Pre-shared k3s cluster token // Pre-shared k3s cluster token
const k3sToken = config.requireSecret("k3sToken"); const k3sToken = config.requireSecret("k3sToken");
// Control-plane VIP — floating IP for API server access, ARP-announced by
// kube-vip on whichever master is alive. Added as a TLS SAN on master-1's
// cert below, then used (instead of master1Ip) for every other node's join
// URL and for the exported kubeconfig, so the cluster stays reachable
// through the loss of any single master.
const controlPlaneVipAddress = config.requireSecret("controlPlaneVipAddress");
// VM IDs and CI runner SSH key — read from proxmox-infra stack outputs // VM IDs and CI runner SSH key — read from proxmox-infra stack outputs
const vmIdsOutput = infraRef.requireOutput("vmIds") as pulumi.Output< const vmIdsOutput = infraRef.requireOutput("vmIds") as pulumi.Output<
Record<string, number> Record<string, number>
@@ -124,7 +129,7 @@ const installMaster1 = new command.remote.Command(
"install-k3s-master-1", "install-k3s-master-1",
{ {
connection: conn(master1Ip), connection: conn(master1Ip),
create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_TOKEN='${k3sToken}' sh -s - server --cluster-init --tls-san ${master1Ip} --disable servicelb --node-name k3s-master-1`, create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_TOKEN='${k3sToken}' sh -s - server --cluster-init --tls-san ${master1Ip} --tls-san ${controlPlaneVipAddress} --disable servicelb --node-name k3s-master-1`,
triggers: [master1VmId], triggers: [master1VmId],
}, },
{ dependsOn: [waitMaster1Ssh] }, { dependsOn: [waitMaster1Ssh] },
@@ -141,7 +146,30 @@ const waitK3sMaster1Ready = new command.remote.Command(
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Step 3: Wait for SSH on remaining masters, join cluster // Step 3: Confirm the control-plane VIP is actually reachable before anyone
// else joins, so master-2/master-3 and every worker join through the VIP
// instead of master1Ip directly. The VIP itself is NOT managed here (or by
// any Pulumi stack) — it's a pfSense IP Alias Virtual IP with HAProxy
// TCP-load-balancing 6443 across all three masters, set up manually (see
// "pfSense one-time setup" in this stack's CLAUDE.md). This intentionally
// avoids a second kube-vip/ARP-based VIP for something as critical as API
// access — 03-k8s-infra's existing kube-vip DaemonSet already handles
// Service/LoadBalancer VIPs (e.g. Traefik) separately, entirely inside the
// cluster.
// ---------------------------------------------------------------------------
const waitControlPlaneVipReady = new command.remote.Command(
"wait-control-plane-vip-ready",
{
connection: conn(master1Ip),
create: pulumi.interpolate`for i in $(seq 1 60); do curl -sk --max-time 3 https://${controlPlaneVipAddress}:6443/healthz && exit 0; sleep 5; done; exit 1`,
triggers: [master1VmId],
},
{ dependsOn: [waitK3sMaster1Ready] },
);
// ---------------------------------------------------------------------------
// Step 4: Wait for SSH on remaining masters, join cluster via the VIP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const waitMaster2Ssh = new command.local.Command( const waitMaster2Ssh = new command.local.Command(
@@ -168,24 +196,24 @@ const joinMaster2 = new command.remote.Command(
"join-k3s-master-2", "join-k3s-master-2",
{ {
connection: conn(master2Ip), connection: conn(master2Ip),
create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_TOKEN='${k3sToken}' sh -s - server --server https://${master1Ip}:6443 --disable servicelb --node-name k3s-master-2`, create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_TOKEN='${k3sToken}' sh -s - server --server https://${controlPlaneVipAddress}:6443 --disable servicelb --node-name k3s-master-2`,
triggers: [master2VmId], triggers: [master2VmId],
}, },
{ dependsOn: [waitMaster2Ssh] }, { dependsOn: [waitMaster2Ssh, waitControlPlaneVipReady] },
); );
const joinMaster3 = new command.remote.Command( const joinMaster3 = new command.remote.Command(
"join-k3s-master-3", "join-k3s-master-3",
{ {
connection: conn(master3Ip), connection: conn(master3Ip),
create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_TOKEN='${k3sToken}' sh -s - server --server https://${master1Ip}:6443 --disable servicelb --node-name k3s-master-3`, create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_TOKEN='${k3sToken}' sh -s - server --server https://${controlPlaneVipAddress}:6443 --disable servicelb --node-name k3s-master-3`,
triggers: [master3VmId], triggers: [master3VmId],
}, },
{ dependsOn: [waitMaster3Ssh, joinMaster2] }, { dependsOn: [waitMaster3Ssh, waitControlPlaneVipReady, joinMaster2] },
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Step 4: Join worker nodes // Step 5: Join worker nodes via the VIP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const waitWorker1Ssh = new command.local.Command( const waitWorker1Ssh = new command.local.Command(
@@ -222,7 +250,7 @@ const joinWorker1 = new command.remote.Command(
"join-k3s-worker-1", "join-k3s-worker-1",
{ {
connection: conn(worker1Ip), 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`, create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_URL=https://${controlPlaneVipAddress}:6443 K3S_TOKEN='${k3sToken}' sh -s - --node-name k3s-worker-1`,
triggers: [worker1VmId], triggers: [worker1VmId],
}, },
{ dependsOn: [waitWorker1Ssh] }, { dependsOn: [waitWorker1Ssh] },
@@ -232,7 +260,7 @@ const joinWorker2 = new command.remote.Command(
"join-k3s-worker-2", "join-k3s-worker-2",
{ {
connection: conn(worker2Ip), 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`, create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_URL=https://${controlPlaneVipAddress}:6443 K3S_TOKEN='${k3sToken}' sh -s - --node-name k3s-worker-2`,
triggers: [worker2VmId], triggers: [worker2VmId],
}, },
{ dependsOn: [waitWorker2Ssh] }, { dependsOn: [waitWorker2Ssh] },
@@ -242,20 +270,20 @@ const joinWorker3 = new command.remote.Command(
"join-k3s-worker-3", "join-k3s-worker-3",
{ {
connection: conn(worker3Ip), connection: conn(worker3Ip),
create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_URL=https://${master1Ip}:6443 K3S_TOKEN='${k3sToken}' sh -s - --node-name k3s-worker-3`, create: pulumi.interpolate`curl -sfL https://get.k3s.io | sudo K3S_URL=https://${controlPlaneVipAddress}:6443 K3S_TOKEN='${k3sToken}' sh -s - --node-name k3s-worker-3`,
triggers: [worker3VmId], triggers: [worker3VmId],
}, },
{ dependsOn: [waitWorker3Ssh] }, { dependsOn: [waitWorker3Ssh] },
); );
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Step 5: Format and mount Longhorn disks // Step 6: Format and mount Longhorn disks
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const workers = [worker1Ip, worker2Ip, worker3Ip]; const workers = [worker1Ip, worker2Ip, worker3Ip];
const workerSshReader = [waitWorker1Ssh, waitWorker2Ssh, waitWorker3Ssh]; const workerSshReader = [waitWorker1Ssh, waitWorker2Ssh, waitWorker3Ssh];
const mountLonghornDisks = workers.map((worker, i) => { workers.forEach((worker, i) => {
return new command.remote.Command( return new command.remote.Command(
`mount-longhorn-disk-${i}`, `mount-longhorn-disk-${i}`,
{ {
@@ -274,7 +302,7 @@ const mountLonghornDisks = workers.map((worker, i) => {
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Step 6: Read kubeconfig from master-1, patch server URL for external access // Step 7: Read kubeconfig from master-1, patch server URL to the VIP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const getKubeconfig = new command.remote.Command( const getKubeconfig = new command.remote.Command(
@@ -287,8 +315,63 @@ const getKubeconfig = new command.remote.Command(
{ dependsOn: [joinWorker1, joinWorker2, joinWorker3] }, { dependsOn: [joinWorker1, joinWorker2, joinWorker3] },
); );
// ---------------------------------------------------------------------------
// Step 8: Install additional packages
// ---------------------------------------------------------------------------
const allVms = [
{
id: master1VmId,
ip: master1Ip,
name: "master-1",
readiness: waitMaster1Ssh,
},
{
id: master2VmId,
ip: master2Ip,
name: "master-2",
readiness: waitMaster2Ssh,
},
{
id: master3VmId,
ip: master3Ip,
name: "master-3",
readiness: waitMaster3Ssh,
},
{
id: worker1VmId,
ip: worker1Ip,
name: "worker-1",
readiness: waitWorker1Ssh,
},
{
id: worker2VmId,
ip: worker2Ip,
name: "worker-2",
readiness: waitWorker2Ssh,
},
{
id: worker3VmId,
ip: worker3Ip,
name: "worker-3",
readiness: waitWorker3Ssh,
},
];
allVms.forEach(({ id, ip, name, readiness }) => {
new command.remote.Command(
`install-packages-${name}`,
{
connection: conn(ip),
create: pulumi.interpolate`sudo apt update && sudo apt install -y qemu-guest-agent && sudo systemctl enable --now qemu-guest-agent`,
triggers: [id],
},
{ dependsOn: readiness },
);
});
export const kubeconfig = pulumi.secret( export const kubeconfig = pulumi.secret(
pulumi pulumi
.all([getKubeconfig.stdout, master1Ip]) .all([getKubeconfig.stdout, controlPlaneVipAddress])
.apply(([kc, ip]) => kc.replaceAll("127.0.0.1", ip as string).trim()), .apply(([kc, vip]) => kc.replaceAll("127.0.0.1", vip as string).trim()),
); );