roles/k3s_server, roles/cilium and every node-host playbook hardcoded the dev group
names k3s_primary/k3s_secondary/k3s_servers, so the prod inventory groups could not be
selected at all. That broke the R-ENV-02 promise that prod reuses the SAME roles, and
blocked PROD-PROVISIONING.md §6 outright.
TWO KNOBS, deliberately separate — Ansible templates a play's `hosts:` BEFORE any host
context exists, so group_vars is unreadable there, while roles run WITH host context:
* which hosts a play RUNS ON — k3s_hosts, k3s_primary_hosts/k3s_secondary_hosts
(k3s.yml's split), vpn_egress_gateway_hosts; each `| default(<dev group>)`, set by -e
only. Follows node-health.yml's established `<name>_hosts | default(dev)` pattern.
* which group a ROLE means by "my primary"/"my servers" — k3s_primary_group,
k3s_servers_group in group_vars/all.yml (dev), overridden in group_vars/k3s_prod.
Follows the k3s_api_host convention already in this repo. No per-run flag.
DEV IS UNCHANGED — the key evidence. --list-hosts/--list-tasks/--syntax-check captured
for all 9 playbooks at HEAD and after: sorted host lists IDENTICAL for all 9, same
primary/secondary split, same 6-play site.yml. (Raw --list-hosts PRINT ORDER varies run
to run; proven to be Ansible's own set ordering by running UNMODIFIED HEAD cilium.yml
four times -> 3,1,2 / 1,3,2 / 1,2,3 / 1,2,3. Hence sorted comparison.) --list-tasks
differs in exactly two files, only by added TAGS annotations and one new assert; no task
reordered, renamed or removed. Var resolution verified per environment with -c local:
dev -> k3s_primary/192.168.1.50/sans .50,.49,127.0.0.1,.51,.52; prod ->
k3s_prod_primary/192.168.2.10/vip .9/sans .10,.9,127.0.0.1,.11,.12.
BREAKS THE SMARTMONTOOLS CIRCULAR DEPENDENCY. node_baseline tasks are now tagged
packages/system/disks, so the package install can run alone:
ansible-playbook playbooks/node-baseline.yml -e k3s_hosts=k3s_prod --tags packages
Skipping is the guard — no disks-tagged task is selected, so the
BLOCKED-pending-disk-decision paths are never templated, let alone passed to mkfs.
Belt-and-braces, a new disks-tagged assert fires first in a FULL run and names §2
explicitly instead of failing obscurely at mkfs. `tags: never` was deliberately NOT used
(it would change the default run).
FOOTGUN FOUND AND FIXED: roles/cilium, cluster_verify and flux_bootstrap all hardcoded
.cache/dev.kubeconfig — the exact file infra/devcontainer/dev.sh exports as $KUBECONFIG.
Once prod became targetable, a prod cilium/verify run would have SILENTLY OVERWRITTEN it,
leaving the operator's kubectl quietly answering from PROD. Now cluster_kubeconfig_name
(dev.kubeconfig -> prod.kubeconfig). Dev path byte-identical.
KNOWN, DOCUMENTED, NOT FIXED HERE: flux-bootstrap.yml retargets but roles/flux_bootstrap
shells out to bootstrap/flux/bootstrap.sh with a hardcoded --path=clusters/dev — a
bootstrap/ subtree change, not a target-group change (§6, cross-referenced to §7).
vpn.egress.gateway.yml is parameterised but prod has no egress group yet and would need
its own vaulted PIA credentials. --check against prod is still impossible until the prod
vault exists — even the packages-only run needs it.
Nothing was run against the dev cluster: --list-hosts/--list-tasks/--syntax-check, plus
debug/assert with -c local, only.
Serves: R-ENV-01, R-ENV-02, R-ENV-03. Task: T-7.1.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
160 lines
7.2 KiB
YAML
160 lines
7.2 KiB
YAML
---
|
|
# Install k3s as an HA server (embedded etcd) with Cilium-required components
|
|
# disabled (ADR-0001, docs/02 stage 3 "Cluster-join"). The primary runs
|
|
# --cluster-init; secondaries join it with the shared node-token.
|
|
|
|
- name: Download the k3s install script
|
|
ansible.builtin.get_url:
|
|
url: "{{ k3s_install_url }}"
|
|
dest: "{{ k3s_install_script }}"
|
|
mode: "0755"
|
|
|
|
# --node-ip uses k3s_node_ip (the real 192.168.1.x), NOT ansible_host — the latter is
|
|
# now the pfSense NAT SSH target (192.168.2.102:225x) and must not be advertised to k3s.
|
|
- name: Compose the common k3s server flags
|
|
ansible.builtin.set_fact:
|
|
k3s_common_args: >-
|
|
server
|
|
--flannel-backend=none
|
|
--disable-kube-proxy
|
|
--disable-network-policy
|
|
--disable={{ k3s_disable }}
|
|
--write-kubeconfig-mode=644
|
|
{% for san in k3s_tls_sans %}--tls-san={{ san }} {% endfor %}
|
|
--node-ip={{ k3s_node_ip }}
|
|
|
|
# On an ALREADY-installed cluster the install task above is idempotent (creates:
|
|
# k3s.yaml) and won't re-apply new --tls-san flags. Persist the SAN list in
|
|
# /etc/rancher/k3s/config.yaml, which a running k3s reads on the next restart —
|
|
# it then regenerates the serving cert when the SAN set changes, and regenerates
|
|
# its containerd config.toml (so nonroot-devices takes effect). Apply the restart
|
|
# ROLLING, one node at a time (kube-vip keeps the API reachable; etcd quorum holds
|
|
# at 2/3). See playbooks note: run with `serial: 1`.
|
|
- name: Ensure /etc/rancher/k3s exists (a freshly re-provisioned node has no k3s dir until install)
|
|
ansible.builtin.file:
|
|
path: /etc/rancher/k3s
|
|
state: directory
|
|
owner: root
|
|
group: root
|
|
mode: "0755"
|
|
|
|
- name: Write /etc/rancher/k3s/config.yaml (tls-san + nonroot-devices + oidc)
|
|
ansible.builtin.copy:
|
|
dest: /etc/rancher/k3s/config.yaml
|
|
owner: root
|
|
group: root
|
|
mode: "0600"
|
|
content: |
|
|
# Managed by Ansible (roles/k3s_server). Read by k3s in addition to the
|
|
# systemd unit flags; tls-san entries are merged into the serving cert.
|
|
tls-san:
|
|
{% for san in k3s_tls_sans %}
|
|
- "{{ san }}"
|
|
{% endfor %}
|
|
|
|
# Let non-root pods own the raw block devices bind-mounted into them —
|
|
# sets containerd's `device_ownership_from_security_context = true`
|
|
# (k3s >= v1.31.3+k3s1 exposes this as the --nonroot-devices flag).
|
|
#
|
|
# REQUIRED for KubeVirt/CDI on Block-mode PVCs (ADR-0005): CDI's importer,
|
|
# uploader and virt-launcher pods run non-root (UID 107, runAsNonRoot,
|
|
# cap-drop ALL) and open the volume's raw block device (/dev/cdi-block-volume,
|
|
# then the guest disk). Kubernetes only applies fsGroup ownership to
|
|
# *filesystem* volumes — it never chowns a raw block device — and Longhorn's
|
|
# CSIDriver (fsGroupPolicy: ReadWriteOnceWithFSType) skips fsGroup on RWX/Block
|
|
# volumes anyway, so the device stays root:root and the pod fails with
|
|
# `blockdev: cannot open /dev/cdi-block-volume: Permission denied`. This flag
|
|
# makes containerd set the in-container device node's owner from the pod's
|
|
# securityContext, which is the fix the CDI project documents for this case
|
|
# (doc/block_cri_ownership_config.md). Node-scoped and security-neutral: the
|
|
# pod stays non-root, only the device node it already mounts follows its UID/GID.
|
|
# Preserves the longhorn-vm RWX + Block + migratable disk contract unchanged.
|
|
nonroot-devices: true
|
|
|
|
# OIDC (Keycloak SSO for kubectl). The API server trusts ID tokens from the homelab realm;
|
|
# the `kubernetes` PUBLIC client (PKCE) + kubelogin mint them, and RBAC binds the `groups`
|
|
# claim (platform/keycloak/k8s-oidc-rbac.yaml: oidc:platform-admins->cluster-admin,
|
|
# oidc:viewers->view). Nodes resolve keycloak.home.keithjackson.dev to the Cilium gateway VIP
|
|
# (192.168.1.53) and trust its public LE-prod cert, so NO oidc-ca-file is needed. The `oidc:`
|
|
# prefixes namespace federated identities away from cert / service-account subjects.
|
|
# ADDITIVE + break-glass-safe: the cert-based admin kubeconfig keeps full access with or
|
|
# without Keycloak, so a bad realm/OIDC state can never lock you out of the cluster.
|
|
kube-apiserver-arg:
|
|
- "oidc-issuer-url=https://keycloak.home.keithjackson.dev/realms/homelab"
|
|
- "oidc-client-id=kubernetes"
|
|
- "oidc-username-claim=email"
|
|
- "oidc-username-prefix=oidc:"
|
|
- "oidc-groups-claim=groups"
|
|
- "oidc-groups-prefix=oidc:"
|
|
notify: Restart k3s
|
|
|
|
# --- primary: found the cluster --------------------------------------------
|
|
- name: Install k3s primary (cluster-init, embedded etcd)
|
|
ansible.builtin.command:
|
|
cmd: "{{ k3s_install_script }} {{ k3s_common_args }} --cluster-init"
|
|
creates: /etc/rancher/k3s/k3s.yaml
|
|
environment:
|
|
INSTALL_K3S_VERSION: "{{ k3s_version }}"
|
|
when: k3s_server_mode == 'primary'
|
|
|
|
- name: Wait for the k3s node-token to exist (primary)
|
|
ansible.builtin.wait_for:
|
|
path: /var/lib/rancher/k3s/server/token
|
|
timeout: 180
|
|
when: k3s_server_mode == 'primary'
|
|
|
|
- name: Read the k3s node-token (primary)
|
|
ansible.builtin.slurp:
|
|
src: /var/lib/rancher/k3s/server/token
|
|
register: k3s_token_raw
|
|
when: k3s_server_mode == 'primary'
|
|
|
|
- name: Publish the node-token as a fact for secondaries (primary)
|
|
ansible.builtin.set_fact:
|
|
k3s_node_token: "{{ k3s_token_raw.content | b64decode | trim }}"
|
|
when: k3s_server_mode == 'primary'
|
|
|
|
# --- secondaries: obtain the join token, then join the existing cluster -----
|
|
# Fetch the token straight from the primary via delegate_to, falling back to it
|
|
# only when the primary play didn't publish the fact this run. This makes a
|
|
# single-node rejoin (`--limit k3s-dev-N`, primary NOT in the batch) self-
|
|
# contained — delegate_to still reaches the primary even when it isn't a play
|
|
# target, so the join no longer depends on a fact an earlier play had to set.
|
|
# T-1.5 finding F2 (see DECISIONS-LOG.md).
|
|
#
|
|
# "The primary" is resolved through k3s_primary_group (group_vars: `k3s_primary` for
|
|
# dev, `k3s_prod_primary` for prod), never a literal group name — so a prod secondary
|
|
# delegates to the PROD primary using this same role, unchanged (R-ENV-02).
|
|
- name: Fetch the k3s node-token directly from the primary (secondary)
|
|
ansible.builtin.slurp:
|
|
src: /var/lib/rancher/k3s/server/token
|
|
delegate_to: "{{ groups[k3s_primary_group][0] }}"
|
|
register: k3s_primary_token_raw
|
|
when:
|
|
- k3s_server_mode == 'secondary'
|
|
- hostvars[groups[k3s_primary_group][0]].k3s_node_token is not defined
|
|
|
|
- name: Resolve the join token (secondary)
|
|
ansible.builtin.set_fact:
|
|
k3s_join_token: >-
|
|
{{ hostvars[groups[k3s_primary_group][0]].k3s_node_token
|
|
| default(k3s_primary_token_raw.content | b64decode | trim, true) }}
|
|
when: k3s_server_mode == 'secondary'
|
|
|
|
- name: Install k3s secondary (join as server)
|
|
ansible.builtin.command:
|
|
cmd: >-
|
|
{{ k3s_install_script }} {{ k3s_common_args }}
|
|
--server https://{{ k3s_api_host }}:{{ k3s_api_port }}
|
|
creates: /etc/rancher/k3s/k3s.yaml
|
|
environment:
|
|
INSTALL_K3S_VERSION: "{{ k3s_version }}"
|
|
K3S_TOKEN: "{{ k3s_join_token }}"
|
|
when: k3s_server_mode == 'secondary'
|
|
|
|
- name: Wait for the k3s API to be serving locally
|
|
ansible.builtin.wait_for:
|
|
host: "127.0.0.1"
|
|
port: "{{ k3s_api_port }}"
|
|
timeout: 180
|