Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19bf92d6bc | ||
|
|
afac1f338f | ||
|
|
2f57f1822f | ||
|
|
07c4678a6d | ||
|
|
fb1f8db36b | ||
|
|
0ae688ac66 | ||
|
|
b7d5a5c5cd |
No files matched your search
+133
-195
@@ -25,33 +25,121 @@ const worker1Ip = config.requireSecret("worker1Ip");
|
||||
const worker2Ip = config.requireSecret("worker2Ip");
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const pveProvider = new proxmox.Provider("pve", {
|
||||
endpoint: pve1Endpoint,
|
||||
apiToken: pve1ApiToken,
|
||||
insecure: true,
|
||||
});
|
||||
interface PveNodeConfig {
|
||||
nodeName: string;
|
||||
hostLabel: string;
|
||||
isoStore: string;
|
||||
dataStore: string;
|
||||
endpoint: pulumi.Output<string>;
|
||||
apiToken: pulumi.Output<string>;
|
||||
}
|
||||
|
||||
const pveBckpProvider = new proxmox.Provider("pve-bckp", {
|
||||
endpoint: pve2Endpoint,
|
||||
apiToken: pve2ApiToken,
|
||||
insecure: true,
|
||||
});
|
||||
const pveNodeSeeds: PveNodeConfig[] = [
|
||||
{
|
||||
nodeName: "pve",
|
||||
hostLabel: "pve-b550",
|
||||
isoStore: "pve-local-ext1",
|
||||
dataStore: "local-lvm",
|
||||
endpoint: pve1Endpoint,
|
||||
apiToken: pve1ApiToken,
|
||||
},
|
||||
{
|
||||
nodeName: "pve-bckp",
|
||||
hostLabel: "pve-optiplex",
|
||||
isoStore: "local",
|
||||
dataStore: "local",
|
||||
endpoint: pve2Endpoint,
|
||||
apiToken: pve2ApiToken,
|
||||
},
|
||||
{
|
||||
nodeName: "pve",
|
||||
hostLabel: "pve-esprimo",
|
||||
isoStore: "local",
|
||||
dataStore: "local-lvm",
|
||||
endpoint: pve3Endpoint,
|
||||
apiToken: pve3ApiToken,
|
||||
},
|
||||
];
|
||||
|
||||
const pveEsprimoProvider = new proxmox.Provider("pve-esprimo", {
|
||||
endpoint: pve3Endpoint,
|
||||
apiToken: pve3ApiToken,
|
||||
insecure: true,
|
||||
});
|
||||
|
||||
const pveNodes = {
|
||||
"pve-b550": { nodeName: "pve", isoStore: "pve-local-ext1", dataStore: "local-lvm" },
|
||||
"pve-optiplex": { nodeName: "pve-bckp", isoStore: "local", dataStore: "local" },
|
||||
"pve-esprimo": { nodeName: "pve", isoStore: "local", dataStore: "local-lvm" },
|
||||
} as const;
|
||||
const pveNodes = pveNodeSeeds
|
||||
.map((n) => ({
|
||||
...n,
|
||||
provider: new proxmox.Provider(n.hostLabel, {
|
||||
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
|
||||
@@ -73,215 +161,62 @@ const ciRunnerKey = new tls.PrivateKey("ci-runner-key", {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 {
|
||||
name: string;
|
||||
role: "master" | "worker";
|
||||
nodeName: string;
|
||||
provider: proxmox.Provider;
|
||||
template: proxmox.VmLegacy;
|
||||
diskDatastore: string;
|
||||
host: (typeof pveNodes)[number]; // one row from the pveNodes chain
|
||||
ip: pulumi.Output<string>;
|
||||
longhornDiskSize?: number;
|
||||
}
|
||||
|
||||
const hostFor = (label: string) => pveNodes.find((n) => n.hostLabel === label)!;
|
||||
|
||||
const nodeConfigs: NodeConfig[] = [
|
||||
{
|
||||
name: "k3s-master-1",
|
||||
role: "master",
|
||||
nodeName: pveNodes["pve-b550"].nodeName,
|
||||
provider: pveProvider,
|
||||
template: pveTemplate,
|
||||
diskDatastore: pveNodes["pve-b550"].dataStore,
|
||||
host: hostFor("pve-b550"),
|
||||
ip: master1Ip,
|
||||
},
|
||||
{
|
||||
name: "k3s-master-2",
|
||||
role: "master",
|
||||
nodeName: pveNodes["pve-esprimo"].nodeName,
|
||||
provider: pveEsprimoProvider,
|
||||
template: pveTemplate,
|
||||
diskDatastore: pveNodes["pve-esprimo"].dataStore,
|
||||
host: hostFor("pve-esprimo"),
|
||||
ip: master2Ip,
|
||||
},
|
||||
{
|
||||
name: "k3s-worker-1",
|
||||
role: "worker",
|
||||
nodeName: pveNodes["pve-b550"].nodeName,
|
||||
provider: pveProvider,
|
||||
template: pveTemplate,
|
||||
diskDatastore: pveNodes["pve-b550"].dataStore,
|
||||
host: hostFor("pve-b550"),
|
||||
ip: worker1Ip,
|
||||
longhornDiskSize: 50,
|
||||
},
|
||||
{
|
||||
name: "k3s-master-3",
|
||||
role: "master",
|
||||
nodeName: pveNodes["pve-optiplex"].nodeName,
|
||||
provider: pveBckpProvider,
|
||||
template: pveBckpTemplate,
|
||||
diskDatastore: pveNodes["pve-optiplex"].dataStore,
|
||||
host: hostFor("pve-optiplex"),
|
||||
ip: master3Ip,
|
||||
},
|
||||
{
|
||||
name: "k3s-worker-2",
|
||||
role: "worker",
|
||||
nodeName: pveNodes["pve-optiplex"].nodeName,
|
||||
provider: pveBckpProvider,
|
||||
template: pveBckpTemplate,
|
||||
diskDatastore: pveNodes["pve-optiplex"].dataStore,
|
||||
host: hostFor("pve-optiplex"),
|
||||
ip: worker2Ip,
|
||||
longhornDiskSize: 50,
|
||||
},
|
||||
{
|
||||
name: "k3s-worker-3",
|
||||
role: "worker",
|
||||
nodeName: pveNodes["pve-esprimo"].nodeName,
|
||||
provider: pveEsprimoProvider,
|
||||
template: pveEsprimoTemplate,
|
||||
diskDatastore: pveNodes["pve-esprimo"].dataStore,
|
||||
host: hostFor("pve-esprimo"),
|
||||
ip: worker3Ip,
|
||||
longhornDiskSize: 50,
|
||||
},
|
||||
@@ -292,14 +227,14 @@ const k3sVms = nodeConfigs.map(
|
||||
new proxmox.VmLegacy(
|
||||
node.name,
|
||||
{
|
||||
nodeName: node.nodeName,
|
||||
nodeName: node.host.nodeName,
|
||||
name: node.name,
|
||||
description: "k3s " + node.role + " node — managed by Pulumi",
|
||||
tags: ["k3s", node.role],
|
||||
clone: {
|
||||
vmId: node.template.vmId,
|
||||
vmId: node.host.pveTemplate.vmId,
|
||||
full: true,
|
||||
datastoreId: node.diskDatastore,
|
||||
datastoreId: node.host.dataStore,
|
||||
},
|
||||
cpu: {
|
||||
cores: 2,
|
||||
@@ -314,7 +249,7 @@ const k3sVms = nodeConfigs.map(
|
||||
disks: [
|
||||
{
|
||||
interface: "scsi0",
|
||||
datastoreId: node.diskDatastore,
|
||||
datastoreId: node.host.dataStore,
|
||||
size: 35,
|
||||
ssd: true,
|
||||
discard: "on",
|
||||
@@ -324,7 +259,7 @@ const k3sVms = nodeConfigs.map(
|
||||
: [
|
||||
{
|
||||
interface: "scsi1",
|
||||
datastoreId: node.diskDatastore,
|
||||
datastoreId: node.host.dataStore,
|
||||
size: node.longhornDiskSize,
|
||||
ssd: true,
|
||||
discard: "on",
|
||||
@@ -332,13 +267,15 @@ const k3sVms = nodeConfigs.map(
|
||||
]),
|
||||
],
|
||||
initialization: {
|
||||
datastoreId: node.diskDatastore,
|
||||
datastoreId: node.host.dataStore,
|
||||
ipConfigs: [{ ipv4: { address: "dhcp" } }],
|
||||
userAccount: {
|
||||
username: "ubuntu",
|
||||
password: k3sVmPassword,
|
||||
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()),
|
||||
],
|
||||
},
|
||||
@@ -347,12 +284,13 @@ const k3sVms = nodeConfigs.map(
|
||||
scsiHardware: "virtio-scsi-pci",
|
||||
serialDevices: [{}],
|
||||
vga: { type: "serial0" },
|
||||
agent: { enabled: true },
|
||||
agent: { enabled: true, timeout: "30s" },
|
||||
cdrom: { fileId: "none" },
|
||||
started: false,
|
||||
stopOnDestroy: true,
|
||||
},
|
||||
{
|
||||
provider: node.provider,
|
||||
provider: node.host.provider,
|
||||
retainOnDelete: true,
|
||||
ignoreChanges: ["clone", "started"],
|
||||
},
|
||||
|
||||
@@ -7,12 +7,12 @@ Bootstraps a k3s cluster on the Proxmox VMs created by `proxmox-infra`. Starts V
|
||||
## How it works
|
||||
|
||||
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`)
|
||||
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`, `k3s-worker-2`, and `k3s-worker-3` as agent nodes
|
||||
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. 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 via `--server https://<controlPlaneVipAddress>:6443`
|
||||
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`
|
||||
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.
|
||||
|
||||
@@ -29,6 +29,11 @@ pulumi config set master3Ip "192.168.1.x"
|
||||
pulumi config set worker1Ip "192.168.1.x"
|
||||
pulumi config set worker2Ip "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.
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
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.
|
||||
+107
-24
@@ -3,12 +3,10 @@ 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>;
|
||||
@@ -39,6 +37,13 @@ const worker3Ip = infraRef.requireOutput("worker3Ip");
|
||||
// Pre-shared k3s cluster token
|
||||
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
|
||||
const vmIdsOutput = infraRef.requireOutput("vmIds") as pulumi.Output<
|
||||
Record<string, number>
|
||||
@@ -124,7 +129,7 @@ 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} --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],
|
||||
},
|
||||
{ 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(
|
||||
@@ -168,24 +196,24 @@ 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 --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],
|
||||
},
|
||||
{ dependsOn: [waitMaster2Ssh] },
|
||||
{ dependsOn: [waitMaster2Ssh, waitControlPlaneVipReady] },
|
||||
);
|
||||
|
||||
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 --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],
|
||||
},
|
||||
{ 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(
|
||||
@@ -222,7 +250,7 @@ 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`,
|
||||
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],
|
||||
},
|
||||
{ dependsOn: [waitWorker1Ssh] },
|
||||
@@ -232,7 +260,7 @@ 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`,
|
||||
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],
|
||||
},
|
||||
{ dependsOn: [waitWorker2Ssh] },
|
||||
@@ -242,31 +270,31 @@ const joinWorker3 = new command.remote.Command(
|
||||
"join-k3s-worker-3",
|
||||
{
|
||||
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],
|
||||
},
|
||||
{ dependsOn: [waitWorker3Ssh] },
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Step 5: Format and mount Longhorn disks
|
||||
// Step 6: Format and mount Longhorn disks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const workers = [worker1Ip, worker2Ip, worker3Ip];
|
||||
const workerSshReader = [waitWorker1Ssh, waitWorker2Ssh, waitWorker3Ssh];
|
||||
|
||||
const mountLonghornDisks = workers.map((worker, i) => {
|
||||
workers.forEach((worker, i) => {
|
||||
return new command.remote.Command(
|
||||
`mount-longhorn-disk-${i}`,
|
||||
{
|
||||
connection: conn(worker),
|
||||
create: `set -euo pipefail;
|
||||
DISK_ID=$(ls /dev/disk/by-id/ | grep 'scsi1');
|
||||
DISK_PATH="/dev/disk/by-id/$DISK_ID";
|
||||
sudo blkid "$DISK_PATH" >/dev/null 2>&1 || sudo mkfs.ext4 "$DISK_PATH";
|
||||
sudo mkdir -p /mnt/longhorn-extra;
|
||||
echo ""$DISK_PATH" /mnt/longhorn-extra ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab;
|
||||
sudo mount -a;
|
||||
create: `set -euo pipefail;
|
||||
DISK_ID=$(ls /dev/disk/by-id/ | grep 'scsi1');
|
||||
DISK_PATH="/dev/disk/by-id/$DISK_ID";
|
||||
sudo blkid "$DISK_PATH" >/dev/null 2>&1 || sudo mkfs.ext4 "$DISK_PATH";
|
||||
sudo mkdir -p /mnt/longhorn-extra;
|
||||
echo ""$DISK_PATH" /mnt/longhorn-extra ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab;
|
||||
sudo mount -a;
|
||||
df -h /mnt/longhorn-extra`,
|
||||
},
|
||||
{ dependsOn: workerSshReader[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(
|
||||
@@ -287,8 +315,63 @@ const getKubeconfig = new command.remote.Command(
|
||||
{ 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(
|
||||
pulumi
|
||||
.all([getKubeconfig.stdout, master1Ip])
|
||||
.apply(([kc, ip]) => kc.replaceAll("127.0.0.1", ip as string).trim()),
|
||||
.all([getKubeconfig.stdout, controlPlaneVipAddress])
|
||||
.apply(([kc, vip]) => kc.replaceAll("127.0.0.1", vip as string).trim()),
|
||||
);
|
||||
Reference in new issue
Block a user