cloud-demo/docs/04-tofu.md
CodeGit 4ea6d8b9e5
Some checks failed
terraform / validate (push) Failing after 37s
Updated project to improve guidance
2026-09-03 19:06:27 +01:00

274 lines
12 KiB
Markdown

# Stage 4: Graduating to Terraform/OpenTofu
Stages 2-3 got you a real, working cluster with Flux managing every app in
this repo — on one hand-built VM. This stage replaces that VM with a
proper 3-node cluster provisioned by Terraform/OpenTofu via the
`dmacvicar/libvirt` provider, then points the exact same Flux config at
it. Nothing in `clusters/homelab/` or `apps/` changes — that's the point:
this repo's GitOps state was never tied to *how* the cluster under it got
built.
You'll need `docs/01-bootstrap.md` steps 3-4: the `k3s_token` secret
(generated but unused until now) and the `k8s-readonly` token.
## Where this track actually stands
Earlier notes on this project flagged this whole track as "blocked" on a
provider schema mismatch between the 0.8.x and 0.9.x lines of
`dmacvicar/libvirt`, without pinning down exactly what broke. That's worth
re-examining rather than taking on faith, both because it matters for
whether you should trust `main.tf` and because the process of checking it
is itself a reasonable thing to learn from — so here's what's actually
been confirmed, and what hasn't, as of this pass:
**Confirmed: this isn't really a "0.8.x vs 0.9.x" ambiguity at all.**
`terraform/versions.tf` pins `~> 0.8`. Checking the provider's own release
history: v0.9.0 (Nov 2025) was an intentional, permanent fork to a fully
regenerated schema that maps 1:1 onto libvirt's XML — the maintainer's own
release notes say so explicitly, and describe keeping 0.8.x alive in
parallel specifically for people who don't want that rewrite. So `~> 0.8`
doesn't risk drifting onto 0.9.x schema by accident; it's a deliberate,
stable choice, not an unresolved question.
**Confirmed: every resource in `main.tf` matches the real 0.8.x schema.**
Checked directly against this provider's own docs at git tag `v0.8.3`
(`website/docs/r/{pool,volume,cloudinit,domain,network}.html.markdown` in
`dmacvicar/terraform-provider-libvirt`) — not from memory, and not from
whatever an LLM's training data assumes a "libvirt provider" looks like,
which is the trap the project's own earlier notes were rightly worried
about. Every attribute `main.tf` uses lines up: `libvirt_pool`'s
`type = "dir"`; `libvirt_volume`'s `base_volume_id`/`size`/`format`;
`libvirt_cloudinit_disk`'s `user_data`/`network_config`;
`libvirt_domain`'s `disk { volume_id }`, `network_interface { network_id,
wait_for_lease }`, and `console { type, target_type, target_port }`;
`libvirt_network`'s `mode`/`addresses`/`dhcp { enabled }`/`dns { enabled }`.
None of it uses 0.9.x-only shapes (nested `create.content.url`,
`backing_store`, `capacity` instead of `size`, etc.).
**Not yet confirmed: whether it actually applies.** Static schema-matching
isn't the same as a real `tofu apply` succeeding — that needs the T630's
actual libvirt socket, which nothing has exercised end-to-end yet. If
you're picking this stage up, this is the real remaining unknown, and a
reasonable order to close it:
1. **Free, no-VM checkpoint first**: `.forgejo/workflows/terraform.yml`
already runs `tofu init -backend=false` + `tofu validate` on every push
to `terraform/**` — that's a real, automated check of exactly the
schema question above, running today. Check its latest result in
Forgejo Actions before doing anything else; if it's failing, the error
message will point at a specific resource/attribute far faster than
re-deriving the whole schema by hand.
2. **`tofu init` on the T630** (step 2 below) and check
`terraform/.terraform.lock.hcl` afterwards — confirm it actually
resolved a `0.8.x` version, not something unexpected.
3. **`tofu plan`, then `tofu apply`**, and if any single resource fails,
treat that resource in isolation: re-check its specific arguments
against `website/docs/r/<resource>.html.markdown` at whatever version
`.terraform.lock.hcl` actually resolved (not `main` — the docs move
with the schema, and `main` may already reflect a newer 0.8.x patch or
even post-fork changes), rather than guessing at a fix. That's a more
targeted version of the same check already done above for the whole
file.
4. Once a full `tofu apply` succeeds once, the remaining steps below are
what actually plug the result into the rest of this project.
Sizing is deliberately small (3 VMs, 2 vCPU/2GB RAM each = 6 vCPU/6GB
total) so this stays a demo rather than competing with whatever else is
already running on the T630.
---
## 1. Install OpenTofu
Installing the package needs sudo, so that part is you (`maq`), not `k8s`.
Installing system-wide (`/usr/local/bin`) means `k8s` can just use `tofu`
afterwards with no further root involvement:
```sh
# as maq (has sudo)
sudo apt install -y unzip
curl -fsSL https://get.opentofu.org/install-opentofu.sh -o install-opentofu.sh
sudo sh install-opentofu.sh --install-method standalone && rm install-opentofu.sh
```
## 2. Provision the VMs with OpenTofu
Everything from here on is `k8s` again (`sudo -iu k8s`), no sudo involved —
clone using the `k8s-readonly` token from `docs/01-bootstrap.md` step 4.
`k8s` has no keyring (it's headless, no desktop session), so this uses
`git credential-store` — a plaintext file, `chmod 600`'d, holding only the
read-only token:
```sh
# as k8s
git config --global credential.helper store
git clone https://git.boglabob.com/codegit/cloud-demo.git ~/k3s
# prompts for username (anything) and password (paste the k8s-readonly
# token) once; stores it in ~/.git-credentials for next time
chmod 600 ~/.git-credentials
cd ~/k3s/terraform
cp terraform.tfvars.example terraform.tfvars
# edit terraform.tfvars: ssh_public_key (contents of ~/.ssh/id_ed25519.pub
# from docs/01-bootstrap.md step 2), k3s_token (from step 3). Defaults for
# network/sizing are fine to start.
tofu init
tofu plan
tofu apply
```
For any later change to `terraform/`: edit and push as `maq` as usual, then
`cd ~/k3s && git pull` as `k8s` before re-running `tofu plan`/`apply`.
This brings up `k3s-server-1`, `k3s-agent-1`, `k3s-agent-2` on the
`k3s-homelab` libvirt network (`10.20.30.0/24` by default — isolated from
anything else already using libvirt on this box, including the `default`
network stage 2's manual VM used). Cloud-init installs k3s on each on
first boot — give it ~2 minutes after `apply` finishes.
## 3. Get kubectl talking to the new cluster
```sh
mkdir -p ~/.kube
ssh k3s@$(tofu output -raw server_ip) sudo cat /etc/rancher/k3s/k3s.yaml \
| sed "s/127.0.0.1/$(tofu output -raw server_ip)/" > ~/.kube/config-homelab
export KUBECONFIG=~/.kube/config-homelab
kubectl get nodes # expect 3 Ready nodes
```
This works as-is because you're running it on the T630, which can reach the
`10.20.30.0/24` network directly. To also use kubectl from your own laptop,
either `scp` this kubeconfig over and open an SSH tunnel first
(`ssh -L 6443:10.20.30.11:6443 k8s@t630`, then point the kubeconfig's
`server:` at `https://127.0.0.1:6443`), or just SSH into the T630 as `k8s`
whenever you need kubectl — simplest by far for a project this size.
## 4. Re-bootstrap Flux against the new cluster
This is the exact same `flux bootstrap git` command from `docs/03-flux.md`
step 2, just pointed at `~/.kube/config-homelab` instead of
`~/.kube/config-manual` — and it needs to actually run again, not be
skipped. The new 3-node cluster has its own fresh etcd/SQLite; nothing
about the manual VM's cluster state carries over to it, `flux-system`
namespace included. What *does* carry over is this repo:
```sh
flux check --pre --kubeconfig ~/.kube/config-homelab
flux bootstrap git \
--url=https://git.boglabob.com/codegit/cloud-demo \
--branch=main \
--path=clusters/homelab \
--username=codegit \
--password=<FLUX_WRITE_TOKEN> \
--token-auth \
--kubeconfig ~/.kube/config-homelab
```
Because `clusters/homelab/flux-system/` already holds the exact manifests
stage 3's bootstrap generated, this run doesn't need to commit anything
new back to the repo — it just applies that already-correct config to a
cluster that doesn't have it yet. That's the whole point of GitOps having
been the deploy mechanism all along: the desired state was never tied to
the specific cluster instance, so pointing the same bootstrap command at a
new kubeconfig reproduces it exactly. Confirm:
```sh
flux get kustomizations --watch
kubectl -n podinfo get pods
kubectl -n hello-app get pods
kubectl -n headlamp get pods
```
## 5. kubectl access from elsewhere on the LAN, or remotely
Best practice for the Kubernetes API server is the same principle as
Headlamp (`docs/03-flux.md` step 5): never put 6443 on the public
internet if you can avoid it, because a leaked credential there is a full
cluster compromise.
Node IPs (`10.20.30.0/24` by default) live on the private libvirt network
from step 2 — only the T630 itself can reach them directly, which is
actually a nice side effect: even the rest of your LAN can't touch the API
server without going through the T630 first. Two ways to do that:
- **SSH into the T630 as `k8s`** and run kubectl there directly (same as
step 3) — simplest, and what this whole project assumes by default.
- **Tunnel from another machine** (your laptop, or a phone via Termux, etc.):
```sh
ssh -L 6443:$(tofu output -raw server_ip):6443 k8s@t630
```
then point a local kubeconfig's `server:` at `https://127.0.0.1:6443`
(copy the kubeconfig from step 3 and edit that one field). The cert
validates because `k8s-api.boglabob.com` is in the server's TLS SAN list
(`terraform/variables.tf`'s `k8s_api_hostname`) — add it to
`/etc/hosts` as `127.0.0.1 k8s-api.boglabob.com` on whatever machine
you're tunneling from and use that as the `server:` host instead of the
raw IP, so the hostname in the URL matches a name the cert actually
covers. (This SAN entry is new in stage 4 — the manual VM's cloud-init
in `docs/02-k3s.md` never set one, since nothing needed LAN-wide access
to it.)
- **From outside your home network entirely**: Tailscale or WireGuard on
the T630, then the SSH tunnel above over the Tailscale/WireGuard link
instead of the open internet. Reasonable next stretch goal once the core
loop is working — don't port-forward 22 or 6443 on your router for this.
The `admin-user` bearer token (`docs/03-flux.md` step 5) also works for
kubectl over the same tunnel, if you'd rather not manage the client-cert
kubeconfig.
## 6. Point Caddy and Headlamp at the new cluster
Same steps as `docs/03-flux.md` step 8 (Caddy) and step 5 (Headlamp) — the
mechanism is identical, only the node IP(s) changed. Caddy now has three
node IPs to pick from instead of one, so use `docs/Caddyfile.example`'s
`reverse_proxy` blocks (which list all three) rather than the single-IP
version from stage 3:
```sh
tofu output node_ips
```
```sh
podman exec <caddy-container> caddy reload --config /etc/caddy/Caddyfile
```
Headlamp's port-forward command is unchanged (`kubectl` just needs
`KUBECONFIG` pointed at `~/.kube/config-homelab` now).
## 7. Tearing down the manual VM
Now that the real 3-node cluster is up, remove stage 2's throwaway one:
```sh
virsh -c qemu:///system destroy k3s-manual # stop it
virsh -c qemu:///system undefine k3s-manual --remove-all-storage # VM + overlay disk
virsh -c qemu:///system vol-delete --pool default k3s-manual-base.qcow2 # base image isn't
# attached to the VM
# directly, needs its
# own delete
rm ~/.kube/config-manual
rm -rf ~/vms
```
Nothing else needs cleaning up — Flux's own state lived entirely inside
that VM's cluster and goes away with it. The Forgejo repo, both tokens,
and the Forgejo Actions runner registration are all cluster-independent
and already carried over unchanged in step 4 above.
## Stretch goals, roughly in order
- **Remote access**: Tailscale or WireGuard on the k3s server node, for
kubectl/Headlamp access from outside the LAN without opening anything
publicly (step 5).
- **TLS**: `cert-manager` + a `ClusterIssuer` for Let's Encrypt (DNS-01 if
`boglabob.com` isn't publicly reachable on 80/443).
- **Secrets in Git**: `sops` + `age`, or `sealed-secrets`, so the
`K3S_TOKEN`/API tokens above don't need to live only in Forgejo's secret
store.
- **Monitoring**: `kube-prometheus-stack` via Helm, deployed the same way as
podinfo (HelmRepository + HelmRelease under `apps/`).
- **HA**: add a second k3s server node and switch from SQLite to embedded
etcd (`--cluster-init` on the first server, `--server` join on the second).