Compare commits

...

3 Commits

Author SHA1 Message Date
Techno Tim cf76292169 feat(site): fail fast when cluster nodes share a hostname (#697)
Add a preflight assertion to site.yml that confirms every host in the
k3s_cluster group reports a unique hostname. k3s registers each node keyed by
its hostname, so duplicate hostnames prevent nodes from joining the cluster
and are hard to troubleshoot. The check fails fast in the Pre tasks instead of
surfacing as a cryptic registration failure later.

- site.yml: assert (cluster_hostnames | unique | length) == length for the
  k3s_cluster group, skipping hosts outside that group
- README.md: document the unique-hostname requirement
- .github/scripts/test-unique-hostname-precheck.sh: regression test
- .pre-commit-config.yaml: wire the test into pre-commit

Closes #636
2026-08-05 05:14:29 -05:00
Techno Tim 52c086d638 feat(metallb): add option to limit MetalLB layer2 announcements to specific interfaces (#696)
- roles/k3s_server_post/templates/metallb.crs.j2: render spec.interfaces in the
  L2Advertisement when metal_lb_interfaces is a non-empty list, so MetalLB only
  announces on the configured interfaces. Empty list (default) keeps announcing
  on all interfaces, preserving existing behavior.
- roles/k3s_server_post/defaults/main.yml: add metal_lb_interfaces: [] default
- roles/k3s_server_post/meta/main.yml: add metal_lb_interfaces argument_spec
- inventory/sample/group_vars/all.yml: document the new sample variable
- .github/scripts/test-metallb-interfaces.py: regression test rendering the
  template for empty/single/multiple interfaces and confirming the BGP path is
  unaffected
- .pre-commit-config.yaml: wire the new test into pre-commit

Co-authored-by: Leo <leo@kuboschek.me>
2026-08-05 02:48:24 -05:00
Techno Tim 010551b8d2 feat(cilium): add toggle to enable or disable the Envoy proxy (#695)
Add a `cilium_envoy` variable (default true, matching upstream Cilium 1.20
which installs Envoy by default) that controls whether the Envoy proxy is
deployed for Cilium L7 policies. Pass it to Helm as `envoy.enabled` so users
with no L7 policies can skip Envoy to save resources.

- roles/k3s_server_post/defaults/main.yml: add cilium_envoy: true default
- roles/k3s_server_post/tasks/cilium.yml: add --helm-set envoy.enabled to the
  install/upgrade command, driven by the cilium_envoy conditional
- inventory/sample/group_vars/all.yml: document cilium_envoy sample var
- .github/scripts/test-cilium-envoy-toggle.py: regression test asserting the
  install command carries the envoy.enabled helm-set and renders true/false
- .pre-commit-config.yaml: wire the new test into pre-commit

Co-authored-by: Léo Nonnenmacher <leo@nonnenmacher-logel.fr>
2026-08-05 00:00:39 -05:00
11 changed files with 306 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Regression test for the Cilium Envoy toggle.
The `cilium_envoy` variable lets users enable or disable the Cilium Envoy
proxy. The Install/upgrade Cilium task in
roles/k3s_server_post/tasks/cilium.yml passes the value through to Helm as
`envoy.enabled`. This test:
- loads the real "Install Cilium" task and confirms the install/upgrade
command actually contains the `envoy.enabled` Helm value,
- renders the conditional that computes the Helm value and confirms it
produces `true` when cilium_envoy is enabled and `false` when disabled,
- confirms the task stays forward/backward compatible (no raw `true` /
`false` hardcoded in place of the conditional).
"""
from __future__ import print_function
import os
import re
import subprocess
import yaml
from jinja2 import Environment
ENVOY_EXPRESSION = '{{ "true" if cilium_envoy else "false" }}'
def repo_root():
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
def fail(message):
raise SystemExit("Cilium Envoy toggle test failed: " + message)
def extract_install_command(path):
"""Return the command string for the 'Install Cilium' task.
Walks both top-level tasks and tasks nested inside a `block`/`always`/
`rescue` list, since the Cilium deploy steps are grouped under the
'Prepare Cilium CLI on first master and deploy CNI' block.
"""
with open(path, encoding="utf-8") as handle:
doc = yaml.safe_load(handle)
def find_command(tasks):
for task in tasks:
if not isinstance(task, dict):
continue
if task.get("name") == "Install Cilium":
command = task.get("ansible.builtin.command")
if command is None:
raise SystemExit(
"Cilium Envoy toggle test failed: "
"'Install Cilium' task has no ansible.builtin.command"
)
return command
# Recurse into block/always/rescue sub-lists.
for key in ("block", "always", "rescue"):
nested = task.get(key)
if isinstance(nested, list):
found = find_command(nested)
if found is not None:
return found
return None
command = find_command(doc)
if command is None:
raise SystemExit(
"Cilium Envoy toggle test failed: could not find 'Install Cilium' task"
)
return command
def assert_envoy_in_command(command):
if "envoy.enabled" not in command:
fail("install command is missing --helm-set envoy.enabled")
if ENVOY_EXPRESSION not in command:
fail(
"install command does not use the cilium_envoy conditional: "
"expected {0!r}".format(ENVOY_EXPRESSION)
)
# The conditional must be a WYSIWYG helm-set value, not a pre-rendered
# true/false literal (which would ignore the cilium_envoy variable).
if re.search(r"--helm-set envoy\.enabled=true(?:$|\s)", command):
fail("install command hardcodes envoy.enabled=true")
if re.search(r"--helm-set envoy\.enabled=false(?:$|\s)", command):
fail("install command hardcodes envoy.enabled=false")
def assert_render():
env = Environment()
def render_for(value):
template = env.from_string(ENVOY_EXPRESSION)
return template.render(cilium_envoy=value)
if render_for(True) != "true":
fail("envoy conditional did not render 'true' when enabled")
if render_for(False) != "false":
fail("envoy conditional did not render 'false' when disabled")
def main():
root = repo_root()
cilium_tasks = os.path.join(
root, "roles", "k3s_server_post", "tasks", "cilium.yml"
)
command = extract_install_command(cilium_tasks)
assert_envoy_in_command(command)
assert_render()
print("Cilium Envoy toggle regression test passed")
if __name__ == "__main__":
main()
@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""Regression test for the MetalLB L2Advertisement interfaces.
`metal_lb_interfaces` restricts which network interfaces MetalLB announces
load balancer IPs on in layer2 mode. When the list is non-empty, the
L2Advertisement in roles/k3s_server_post/templates/metallb.crs.j2 must render
a `spec.interfaces` block; when it is empty (the default), no spec is rendered
so MetalLB announces on all interfaces.
This renders the template and asserts both cases plus the BGP path (which must
not be affected by the L2 interfaces variable).
"""
from __future__ import print_function
import os
import subprocess
from jinja2 import Environment, FileSystemLoader, StrictUndefined
def repo_root():
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
def fail(message):
raise SystemExit("MetalLB interfaces test failed: " + message)
def render(env, extra_vars):
base_vars = {
"metal_lb_mode": "layer2",
"metal_lb_ip_range": "192.168.30.80-192.168.30.90",
}
base_vars.update(extra_vars)
template = env.get_template("metallb.crs.j2")
return template.render(**base_vars)
def main():
root = repo_root()
template_dir = os.path.join(
root, "roles", "k3s_server_post", "templates"
)
env = Environment(
loader=FileSystemLoader(template_dir), undefined=StrictUndefined
)
# Empty list (default): no spec.interfaces in the L2Advertisement.
output = render(env, {"metal_lb_interfaces": []})
if "spec:\n interfaces:" in output:
fail("spec.interfaces rendered with an empty metal_lb_interfaces")
if "kind: L2Advertisement" not in output:
fail("L2Advertisement missing in layer2 mode")
# Single interface.
output = render(env, {"metal_lb_interfaces": ["eth1"]})
if "spec:\n interfaces:\n - eth1" not in output:
fail("single interface was not rendered in spec.interfaces")
# Multiple interfaces.
output = render(env, {"metal_lb_interfaces": ["eth1", "eth2"]})
if "spec:\n interfaces:\n - eth1\n - eth2" not in output:
fail("multiple interfaces were not rendered in spec.interfaces")
# BGP mode must not emit an L2Advertisement spec at all.
output = render(
env,
{
"metal_lb_mode": "bgp",
"metal_lb_interfaces": ["eth1"],
"metal_lb_bgp_my_asn": "64513",
"metal_lb_bgp_peer_asn": "64512",
"metal_lb_bgp_peer_address": "192.168.30.1",
},
)
if "kind: L2Advertisement" in output:
fail("L2Advertisement rendered in bgp mode")
if "interfaces:" in output:
fail("interfaces rendered in bgp mode")
print("MetalLB interfaces regression test passed")
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repo_root="$(git rev-parse --show-toplevel)"
site_play="$repo_root/site.yml"
# #636: verify the "Pre tasks" play asserts that all k3s_cluster hosts have
# unique hostnames, so a duplicate-hostname inventory fails fast instead of
# silently breaking node registration/joining.
grep -Fq -- 'Verify all cluster nodes have unique hostnames' "$site_play" || {
printf 'site.yml is missing the unique-hostname preflight check\n' >&2
exit 1
}
# The check must deduplicate the cluster hostname list via the `unique` filter
# and compare lengths, i.e. groups['k3s_cluster'] must be referenced.
grep -Fq -- "groups['k3s_cluster']" "$site_play" || {
printf 'unique-hostname check does not iterate the k3s_cluster group\n' >&2
exit 1
}
if ! grep -Eq -- 'cluster_hostnames.*\|.*unique|\| unique' "$site_play"; then
printf 'unique-hostname check does not deduplicate the hostname list\n' >&2
exit 1
fi
printf 'Unique hostname preflight regression test passed\n'
+23
View File
@@ -62,6 +62,12 @@ repos:
language: system language: system
pass_filenames: false pass_filenames: false
files: ^roles/k3s_server/tasks/(main|join_master)\.yml$|^\.github/scripts/test-k3s-server-bootstrap\.sh$ files: ^roles/k3s_server/tasks/(main|join_master)\.yml$|^\.github/scripts/test-k3s-server-bootstrap\.sh$
- id: unique-hostname-precheck-test
name: Unique hostname precheck test
entry: .github/scripts/test-unique-hostname-precheck.sh
language: system
pass_filenames: false
files: ^site\.yml$|^\.github/scripts/test-unique-hostname-precheck\.sh$
- id: cilium-bgp-manifest-test - id: cilium-bgp-manifest-test
name: Cilium BGP manifest test name: Cilium BGP manifest test
entry: python3 .github/scripts/test-cilium-bgp-manifest.py entry: python3 .github/scripts/test-cilium-bgp-manifest.py
@@ -70,6 +76,15 @@ repos:
- Jinja2>=3.1 - Jinja2>=3.1
pass_filenames: false pass_filenames: false
files: ^roles/k3s_server_post/templates/cilium\.crs\.j2$|^\.github/scripts/test-cilium-bgp-manifest\.py$ files: ^roles/k3s_server_post/templates/cilium\.crs\.j2$|^\.github/scripts/test-cilium-bgp-manifest\.py$
- id: cilium-envoy-toggle-test
name: Cilium Envoy toggle test
entry: python3 .github/scripts/test-cilium-envoy-toggle.py
language: python
additional_dependencies:
- Jinja2>=3.1
- PyYAML
pass_filenames: false
files: ^roles/k3s_server_post/tasks/cilium\.yml$|^\.github/scripts/test-cilium-envoy-toggle\.py$
- id: kube-vip-manifest-test - id: kube-vip-manifest-test
name: kube-vip manifest test name: kube-vip manifest test
entry: python3 .github/scripts/test-kube-vip-manifest.py entry: python3 .github/scripts/test-kube-vip-manifest.py
@@ -84,6 +99,14 @@ repos:
language: system language: system
pass_filenames: false pass_filenames: false
files: ^roles/k3s_server/tasks/metallb\.yml$|^\.github/scripts/test-metallb-remote-read\.sh$ files: ^roles/k3s_server/tasks/metallb\.yml$|^\.github/scripts/test-metallb-remote-read\.sh$
- id: metallb-interfaces-test
name: MetalLB interfaces test
entry: python3 .github/scripts/test-metallb-interfaces.py
language: python
additional_dependencies:
- Jinja2>=3.1
pass_filenames: false
files: ^roles/k3s_server_post/templates/metallb\.crs\.j2$|^\.github/scripts/test-metallb-interfaces\.py$
- id: metallb-deploy-condition-test - id: metallb-deploy-condition-test
name: MetalLB deploy condition test name: MetalLB deploy condition test
entry: python3 .github/scripts/test-metallb-deploy-condition.py entry: python3 .github/scripts/test-metallb-deploy-condition.py
+4
View File
@@ -50,6 +50,10 @@ Supported processor architectures are:
- Server and agent nodes should support passwordless SSH access. Otherwise, pass `--ask-pass --ask-become-pass` to - Server and agent nodes should support passwordless SSH access. Otherwise, pass `--ask-pass --ask-become-pass` to
each playbook command. each playbook command.
- Every node in the cluster must have a **unique hostname**. k3s registers each node keyed by its hostname, so
two nodes with the same hostname cannot join the cluster. `site.yml` asserts this up front and fails fast if
any duplicate is found.
## 🚀 Getting Started ## 🚀 Getting Started
### 🍴 Preparation ### 🍴 Preparation
+10
View File
@@ -24,6 +24,10 @@ cilium_mode: native # native when nodes are on the same subnet or use BGP, other
cilium_tag: v1.20.0 # cilium version tag cilium_tag: v1.20.0 # cilium version tag
cilium_cli_tag: v0.19.7 # cilium cli version tag cilium_cli_tag: v0.19.7 # cilium cli version tag
cilium_hubble: true # enable hubble observability relay and ui cilium_hubble: true # enable hubble observability relay and ui
cilium_envoy: true # enable the Envoy proxy for Cilium L7 policies
# disable cilium_envoy to skip the Envoy proxy entirely (e.g. no L7 policies)
# cilium_envoy: false
# if using calico or cilium, you may specify the cluster pod cidr pool # if using calico or cilium, you may specify the cluster pod cidr pool
cluster_cidr: 10.52.0.0/16 cluster_cidr: 10.52.0.0/16
@@ -116,6 +120,12 @@ metal_lb_controller_tag_version: v0.16.0
# metallb ip range for load balancer # metallb ip range for load balancer
metal_lb_ip_range: 192.168.30.80-192.168.30.90 metal_lb_ip_range: 192.168.30.80-192.168.30.90
# (optional) limit MetalLB layer2 announcements to specific network interfaces.
# Leave empty (default) to announce on all interfaces.
# metal_lb_interfaces:
# - eth1
# - eth2
# Only enable if your nodes are proxmox LXC nodes, make sure to configure your proxmox nodes # Only enable if your nodes are proxmox LXC nodes, make sure to configure your proxmox nodes
# in your hosts.ini file. # in your hosts.ini file.
# Please read https://gist.github.com/triangletodd/02f595cd4c0dc9aac5f7763ca2264185 before using this. # Please read https://gist.github.com/triangletodd/02f595cd4c0dc9aac5f7763ca2264185 before using this.
+2
View File
@@ -18,6 +18,7 @@ cilium_bgp_peer_asn: 64512
cilium_bgp_neighbors: [] cilium_bgp_neighbors: []
cilium_bgp_neighbors_groups: ['k3s_all'] cilium_bgp_neighbors_groups: ['k3s_all']
cilium_bgp_lb_cidr: 192.168.31.0/24 cilium_bgp_lb_cidr: 192.168.31.0/24
cilium_envoy: true
cilium_hubble: true cilium_hubble: true
cilium_mode: native cilium_mode: native
cilium_tag: v1.20.0 cilium_tag: v1.20.0
@@ -38,4 +39,5 @@ group_name_master: master
metal_lb_mode: layer2 metal_lb_mode: layer2
metal_lb_available_timeout: 240s metal_lb_available_timeout: 240s
metal_lb_controller_tag_version: v0.16.0 metal_lb_controller_tag_version: v0.16.0
metal_lb_interfaces: []
metal_lb_ip_range: 192.168.30.80-192.168.30.90 metal_lb_ip_range: 192.168.30.80-192.168.30.90
+8
View File
@@ -141,6 +141,14 @@ argument_specs:
description: MetalLB ip range for load balancer description: MetalLB ip range for load balancer
default: 192.168.30.80-192.168.30.90 default: 192.168.30.80-192.168.30.90
metal_lb_interfaces:
description: >-
List of network interfaces on which MetalLB should announce the
load balancer IPs in layer2 mode. When empty (default), MetalLB
announces on all interfaces.
type: list
default: []
metal_lb_controller_tag_version: metal_lb_controller_tag_version:
description: Image tag for MetalLB description: Image tag for MetalLB
default: v0.16.0 default: v0.16.0
+1
View File
@@ -178,6 +178,7 @@
--helm-set hubble.enabled={{ "true" if cilium_hubble else "false" }} --helm-set hubble.enabled={{ "true" if cilium_hubble else "false" }}
--helm-set hubble.relay.enabled={{ "true" if cilium_hubble else "false" }} --helm-set hubble.relay.enabled={{ "true" if cilium_hubble else "false" }}
--helm-set hubble.ui.enabled={{ "true" if cilium_hubble else "false" }} --helm-set hubble.ui.enabled={{ "true" if cilium_hubble else "false" }}
--helm-set envoy.enabled={{ "true" if cilium_envoy else "false" }}
{% if kube_proxy_replacement is not false %} {% if kube_proxy_replacement is not false %}
--helm-set loadBalancer.algorithm={{ bpf_lb_algorithm }} --helm-set loadBalancer.algorithm={{ bpf_lb_algorithm }}
--helm-set loadBalancer.mode={{ bpf_lb_mode }} --helm-set loadBalancer.mode={{ bpf_lb_mode }}
@@ -21,6 +21,11 @@ kind: L2Advertisement
metadata: metadata:
name: default name: default
namespace: metallb-system namespace: metallb-system
{% if metal_lb_interfaces | default([]) | length > 0 %}
spec:
interfaces:{% for iface in metal_lb_interfaces %}
- {{ iface }}{% endfor %}
{% endif %}
{% endif %} {% endif %}
{% if metal_lb_mode == "bgp" %} {% if metal_lb_mode == "bgp" %}
--- ---
+17
View File
@@ -8,6 +8,23 @@
msg: > msg: >
"Ansible is out of date. See here for more info: https://docs.technotim.com/posts/ansible-automation/" "Ansible is out of date. See here for more info: https://docs.technotim.com/posts/ansible-automation/"
- name: Verify all cluster nodes have unique hostnames
ansible.builtin.assert:
that: (cluster_hostnames | unique | length) == (cluster_hostnames | length)
msg: >-
k3s nodes must have unique hostnames. Found a duplicate in:
{{ cluster_hostnames | unique }}. Each node registers in the cluster
keyed by its hostname, so matching hostnames prevent nodes from joining.
vars:
cluster_hostnames: >-
{{
groups['k3s_cluster']
| map('extract', hostvars, 'ansible_hostname')
| list
}}
run_once: true
when: "'k3s_cluster' in groups"
- name: Prepare Proxmox cluster - name: Prepare Proxmox cluster
hosts: proxmox hosts: proxmox
gather_facts: true gather_facts: true