Compare commits

...
Author SHA1 Message Date
dependabot[bot] bd4698693a chore(deps): bump jsonpatch from 1.33 to 1.35
Bumps [jsonpatch](https://github.com/stefankoegl/python-json-patch) from 1.33 to 1.35.
- [Release notes](https://github.com/stefankoegl/python-json-patch/releases)
- [Commits](https://github.com/stefankoegl/python-json-patch/compare/v1.33...v1.35)

---
updated-dependencies:
- dependency-name: jsonpatch
  dependency-version: '1.35'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-18 18:45:09 -05:00
dependabot[bot] 32bfb3abba chore(deps): bump actions/upload-artifact from 7.0.1 to 7.0.2
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 7.0.1 to 7.0.2.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-18 18:45:09 -05:00
dependabot[bot] dbf7cbd038 chore(deps): bump ansible-core from 2.19.13 to 2.19.14
Bumps [ansible-core](https://github.com/ansible/ansible) from 2.19.13 to 2.19.14.
- [Release notes](https://github.com/ansible/ansible/releases)
- [Commits](https://github.com/ansible/ansible/compare/v2.19.13...v2.19.14)

---
updated-dependencies:
- dependency-name: ansible-core
  dependency-version: 2.19.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-18 18:45:09 -05:00
dependabot[bot] 8fff1d1b95 chore(deps): bump zgosalvez/github-actions-ensure-sha-pinned-actions
Bumps [zgosalvez/github-actions-ensure-sha-pinned-actions](https://github.com/zgosalvez/github-actions-ensure-sha-pinned-actions) from 5.0.9 to 5.0.10.
- [Release notes](https://github.com/zgosalvez/github-actions-ensure-sha-pinned-actions/releases)
- [Commits](https://github.com/zgosalvez/github-actions-ensure-sha-pinned-actions/compare/62574f011e0d1967d555a862bd28a7abba8684fe...c4e71056006d29f90204b2d063be337109507d62)

---
updated-dependencies:
- dependency-name: zgosalvez/github-actions-ensure-sha-pinned-actions
  dependency-version: 5.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-10-18 18:45:09 -05:00
Techno Tim a976f16b21 feat(deps): bump zgosalvez/github-actions-ensure-sha-pinned-actions from 5.0.8 to 5.0.9 (#717) 2026-10-02 22:44:56 +00:00
Marc Palacín 377687cbbe fix(k3s_server): skip k3s-init log collection when the destination is unset (#721)
* fix(k3s_server): skip k3s-init log collection when the destination is unset

The guard on the k3s-init diagnostics include used a boolean default for
the ANSIBLE_K3S_LOG_DIR lookup so that the when conditional would be a real
boolean. That is unnecessary: `log_destination | default('') != ''` is a
comparison and always evaluates to a bool, so ansible-core 2.19 never sees
a non-boolean conditional here.

The boolean default also defeats the guard. With ANSIBLE_K3S_LOG_DIR unset,
log_destination is the bool False, and `False != ''` is True in Python
because False == 0. The include therefore runs on every provisioning run,
executing the journalctl, systemctl, ip, ss, iptables, service_facts, ping
and wait_for diagnostics in fetch_k3s_init_logs.yml, then creating a
directory literally named "False" in the operator's working directory.

Use an empty-string default so the unset case renders "" and the guard
skips. CI did not catch this because .github/workflows/test.yml always
exports ANSIBLE_K3S_LOG_DIR.

Add a regression test that parses the role, renders the real log_destination
expression for both the unset and set cases, and asserts that no boolean
default has been reintroduced.

* chore(site): wrap the over-long kubeconfig fetch conditional
2026-10-02 17:34:55 +00:00
Marc Palacín 1b994f7479 fix(raspberrypi): detect Raspberry Pi hardware in check mode (#719)
- set check_mode: false on the /proc/cpuinfo and /proc/device-tree/model
  probes, since ansible.builtin.command never executes under --check and
  returns rc 0 with "Command would have run if not in check mode"
- default a missing probe rc to 1 in the raspberry_pi gate so an undefined
  result cannot be read as "this host is a Raspberry Pi"
- add a structural regression test and hook it into pre-commit

Without this, site.yml --check marked every x86_64 host as a Raspberry Pi and
applied the distro specific setup tasks, failing on Ubuntu with
"Destination /boot/firmware/cmdline.txt does not exist".
2026-09-30 22:45:00 +00:00
FedelloandTimothy Stewart 8fb1e26c63 fix(ansible): update fact references to remove deprecation warnings (#715)
* chore(ansible): use ansible_facts

* fix(ansible): satisfy lint for fact migration

- wrap nested fact expressions to meet YAML line length limits
- update the bootstrap regression assertion for ansible_facts

* fix(pre-commit): install shellcheck for host architecture

- install shellcheck-py from PyPI in the local hook environment
- pin the platform-aware shellcheck package version

---------

Co-authored-by: Timothy Stewart <timothystewart6@gmail.com>
2026-09-21 17:04:30 +00:00
Timothy Stewart ee3a4a126e chore(deps): bump ansible-core from 2.19.12 to 2.19.13 and zgosalvez/github-actions-ensure-sha-pinned-actions from 5.0.7 to 5.0.8 2026-09-11 10:39:34 -05:00
Marc Palacín 24054385e4 Fix: reboot nodes after installing kernel-modules-extra package (#711)
* fix: nodes reboot after kernel-modules-extra installation

* chore: pre-commit fix
2026-09-05 17:26:52 -05:00
Techno Tim 1a244636ea chore(deps): bump k3s to v1.36.4 (#709)
- Bump k3s_version to v1.36.4+k3s1 in sample group_vars
2026-09-02 00:58:34 -05:00
Timothy Stewart 332f36ceec chore(deps): bump k3s to v1.36.3 and kube-vip to v1.2.3
- Bump k3s_version to v1.36.3+k3s1 in sample group_vars
- Bump kube_vip_tag_version to v1.2.3 in group_vars, server role
  defaults and argument spec, and README variable table
- Update the kube-vip manifest regression test to assert v1.2.3
- Keep MetalLB at v0.16.0: v0.16.1 only exists as a chart-only
  metallb-chart-0.16.1 tag with no application or image release,
  so it must not be used as the controller or speaker image tag
2026-08-14 21:53:28 -05:00
Timothy Stewart c8c3609fde fix(verify): retry the nginx VIP assert on transient ARP miss
- convert the verify_from_outside URI assert to register/until/retries/delay
- retry until the MetalLB VIP returns HTTP 200 with the welcome page
- cover the host-only L2 announcement race that failed CI on the shared runner
- add a regression test asserting the retry wiring and hook it into pre-commit
2026-08-14 18:50:49 -05:00
dependabot[bot] ac77e33be0 chore(deps): bump zgosalvez/github-actions-ensure-sha-pinned-actions
Bumps [zgosalvez/github-actions-ensure-sha-pinned-actions](https://github.com/zgosalvez/github-actions-ensure-sha-pinned-actions) from 5.0.6 to 5.0.7.
- [Release notes](https://github.com/zgosalvez/github-actions-ensure-sha-pinned-actions/releases)
- [Commits](https://github.com/zgosalvez/github-actions-ensure-sha-pinned-actions/compare/46cfe808a5f1588656ef299eedd0ce2fd7ec0dcc...c5fc58bd0be7a4b94b73ce40250322d5b838a108)

---
updated-dependencies:
- dependency-name: zgosalvez/github-actions-ensure-sha-pinned-actions
  dependency-version: 5.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-14 18:50:49 -05:00
dependabot[bot] 070d7b302b chore(deps): bump ansible-core from 2.19.11 to 2.19.12
Bumps [ansible-core](https://github.com/ansible/ansible) from 2.19.11 to 2.19.12.
- [Release notes](https://github.com/ansible/ansible/releases)
- [Commits](https://github.com/ansible/ansible/compare/v2.19.11...v2.19.12)

---
updated-dependencies:
- dependency-name: ansible-core
  dependency-version: 2.19.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-14 18:50:49 -05:00
Timothy Stewart 1687a3b098 feat(k3s-server): add kube_vip_enabled and metal_lb_enabled switches
- gate the control-plane VIP and kube-vip service LB on kube_vip_enabled
- gate the MetalLB manifest and pool on metal_lb_enabled
- add argument_specs entries in both roles
- document both switches in sample inventory and README
- update metal_lb deploy-condition test and add a kube-vip deploy-condition test
2026-08-14 18:50:49 -05:00
Techno Tim 4dc333a7d3 fix(metallb): verify the metallb-system namespace actually exists (#702)
* fix(metallb): verify the metallb-system namespace actually exists

- change the Test metallb-system namespace task to run
  k3s kubectl get namespace metallb-system instead of the bare
  -n metallb-system, which printed kubectl usage and always exited 0
- add a regression test that asserts the task uses an explicit get
  and fails if it ever regresses to the usage-only form
- wire the new test into pre-commit

* fix(metallb): retry the namespace check on transient kube API errors

- retry the explicit get namespace check until it succeeds
- extend the regression test to assert the retry wiring

* fix(metallb): retry the webhook endpoint check on transient kube API errors

- retry the webhook-service endpoint get until it succeeds
- extend the regression test to cover the webhook task too
2026-08-07 22:36:02 -05:00
Techno Tim 287d8b7a27 feat(kube-vip): add endpoint override for the internal listening address (#699)
* feat(kube-vip): add endpoint override for the internal listening address

Add a kube_vip_endpoint variable so the address kube-vip binds and listens on
can differ from the announced apiserver_endpoint. This is useful for complex
routing and site-to-site tunnels where the VIP kube-vip advertises over ARP
differs from the address it listens on internally.

- roles/k3s_server/templates/vip.yaml.j2: use kube_vip_endpoint (defaulting to
  apiserver_endpoint) for the `address` env and for deriving `vip_subnet`
- roles/k3s_server/defaults/main.yml: add kube_vip_endpoint default (null)
- roles/k3s_server/meta/main.yml: add kube_vip_endpoint argument_spec
- inventory/sample/group_vars/all.yml: document the new sample variable
- README.md: document the kube_vip_endpoint option
- .github/scripts/test-kube-vip-manifest.py: extend regression test to cover the
  default (apiserver_endpoint) and the override case

Closes #221

* chore(ci): extend molecule job timeout to 3 hours

The default scenario occasionally takes longer than 150 minutes on the shared
nested-virt runner (k3s agent notify-wait can exceed the limit under load), and
a single timeout aborts the whole run before the other four scenarios execute.
Raise timeout-minutes from 150 to 180 so a slow-but-progressing run completes
instead of aborting.

The default scenario remains first in the matrix so a failure surfaces fastest.

* fix(kube-vip): fall back on null kube_vip_endpoint and cover it in the test

- vip_subnet and address use default(apiserver_endpoint, true) so the null
  role default falls back to the apiserver endpoint instead of rendering an
  empty/invalid address and subnet
- change the manifest regression test default case to pass kube_vip_endpoint
  as None so it pins the real runtime null condition and fails fast on this
  regression rather than timing out in CI
2026-08-06 02:48:57 -05:00
Techno Tim 56bb912bd3 feat(prereq): add toggle to disable swap on all cluster nodes (#698)
k3s recommends swap be disabled on every node. Add a disable_swap toggle
(default true) to the prereq role that turns swap off now and comments out the
swap entries in /etc/fstab so swap stays off across reboots. The change is
applied uniformly across all k3s_cluster hosts (all-or-nothing) since leaving
swap on for some nodes but not others creates uneven scheduling and latency.

- roles/prereq/defaults/main.yml: add disable_swap: true default
- roles/prereq/tasks/main.yml: idempotent swapoff -a and fstab comment-out block
  gated on disable_swap
- inventory/sample/group_vars/all.yml: document the new sample variable
- README.md: document the disable_swap option
- .github/scripts/test-disable-swap.sh: regression test
- .pre-commit-config.yaml: wire the test into pre-commit

Closes #670
2026-08-05 07:39:00 -05:00
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 TimandLeo 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 TimandLéo Nonnenmacher 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
Techno Tim c82f2e0415 chore(deps): apply dependency updates in one combined change (#694)
* chore(deps): apply dependency updates in one combined change

- Bump ansible-core to 2.19.11 and jmespath to 1.1.0 in requirements.in
- Regenerate the Python 3.11 pip-compile lock in requirements.txt
- Bump molecule-plugins to 23.6.0 while keeping molecule on the stable 6.x
  series (avoids the molecule-plugins 26 major jump that broke vagrant module
  resolution in CI)
- Bump ruamel-yaml-clib to 0.2.15
- Bump the zgosalvez/github-actions-ensure-sha-pinned-actions action to 5.0.6
  (SHA-pinned) in lint.yml

* fix(server): make log_destination conditional boolean for ansible-core 2.19

- The always block's 'Save logs of k3s-init.service' task used when: log_destination
  where log_destination is a path string derived from an env var
- ansible-core 2.19 rejects string-derived conditionals; evaluate the path as a
  real boolean (non-empty) check so the conditional is a true boolean
- Required to keep the k3s_server role working with ansible-core 2.19.11 (the
  dependency bump in this change)

* fix(verify): coerce regex_search assertions to bool for ansible-core 2.19

- ansible-core 2.19 requires assert conditionals to be boolean; regex_search
  returns a string, which is now rejected
- Wrap all regex_search results used in assert.that with | bool so the calico,
  cilium, metallb, and kube-vip image-tag checks produce boolean results

* fix(verify): use boolean is regex_search test instead of | bool

- | bool on a regex_search result coerces a tag string like v0.16.0 to False
  in ansible-core 2.19, failing the image-tag assertions
- Use the is regex_search test which returns a real boolean without string
  coercion for the calico, cilium, metallb, and kube-vip image assertions

* fix(verify): use is not none for regex_search assertions

- ansible-core 2.19 has no "is regex_search" test and rejects bool string
  coercion, so use the regex_search filter with an "is not none" comparison,
  which yields a real boolean for the image-tag assertions
- Applies to calico, cilium, metallb, and kube-vip image checks

* fix(metallb): retry transient apiserver resets in config tests

- The Layer 2 and BGP final configuration checks ran a kubectl get per
  resource with no retry, so a transient connection refused from the kube
  API could abort converge
- Mirror the download_retries/download_delay retry pattern used by the
  'Wait for MetalLB resources' task so these checks survive api server
  resets on slow runners
2026-08-04 17:54:58 -05:00
Techno TimandFelix Seifert ac1e3288c0 feat(reboot): support staggered reboots of cluster nodes (#693)
- Add serial to the reboot play so concurrent_reboots controls how many nodes
  reboot at a time, defaulting to 100% (reboot all at once) for backward
  compatibility
- Add a per-node test_command that verifies the node is healthy after reboot
- Add an optional wait_seconds_after_reboot pause between staggered batches so
  pods can settle before the next batch reboots
- Document the new concurrent_reboots and wait_seconds_after_reboot variables

Co-authored-by: Felix Seifert <mail@felix-seifert.com>
2026-08-04 06:11:33 -05:00
Techno Tim db30128468 fix(ci): retry transient DNS failures on remote downloads and cilium install (#690)
- Add shared download_retries/download_delay defaults in k3s_server and k3s_server_post roles
- Retry calico CRD and Tigera operator manifest downloads
- Retry Cilium CLI download and cilium install/upgrade command
- Retry kube-vip cloud provider and MetalLB manifest downloads
- The CI runner's resolver intermittently times out on GitHub-hosted domains
2026-08-04 08:52:16 +00:00
Techno Tim 249238c7a4 fix(metallb): deploy MetalLB with a non-BGP Cilium CNI (#692)
- Correct the Deploy metallb manifest/pool when condition so MetalLB is
  installed whenever kube-vip does not own the VIP range and Cilium BGP
  is disabled
- The previous guard (cilium_bgp is not defined or cilium_iface is not
  defined) skipped MetalLB whenever cilium_iface was set, breaking the
  cilium + MetalLB scenario
- Use cilium_bgp | default(false) | bool to stay safe when Cilium vars are
  not in scope (#644) while still deploying MetalLB for non-BGP Cilium
- Retry the converge-side MetalLB resource wait so a transient kube API
  ServiceUnavailable does not abort the converge play
- Add a regression test that evaluates both when conditions across flannel,
  calico, non-BGP cilium, BGP cilium, and kube-vip scenarios

ci: skip CI for Dependabot pull requests

- Add an actor guard to the CI workflow jobs so automatic Dependabot PRs
  do not consume the shared self-hosted runner
- Dependabot CI runs need maintainer approval instead of auto-running
2026-08-04 01:45:05 -05:00
Techno Tim f5483cdabe fix(flannel): default the interface to each host's default IPv4 interface (#689)
* fix(flannel): default the interface to each host's default IPv4 interface

- Replace the hardcoded flannel_iface: eth0 with a per-host default derived
  from ansible_facts.default_ipv4.interface
- KVM/cloud hosts that are not named eth0 (e.g. enp1s0, ens3) now resolve the
  interface automatically instead of failing the k3s_node_ip lookup
- Update the commented calico_iface / cilium_iface examples to match
- Co-authored-by: Fritz Dunkel <677609+FinalDoom@users.noreply.github.com>
- Fixes #621

* test(flannel): add regression test for per-host interface default

- Add a focused test that renders the sample inventory's flannel_iface
  expression against fake ansible facts
- Assert a non-eth0 host (enp1s0, ens3) resolves its own interface and that
  the expression defaults from ansible_facts.default_ipv4.interface
- Wire it as a local pre-commit hook (default-interface-test)

* test(molecule): assert nodes register a non-loopback InternalIP

- Add a flannel-scenario verify assertion that every node reports an
  InternalIP derived from its configured flannel_iface
- Guards against k3s binding to 127.0.0.1 instead of the cluster interface
- Complements the pre-commit default-interface test with an end-to-end check
2026-08-03 17:01:50 +00:00
Techno Tim 88159b3875 fix(prereq): install kernel-modules-extra when br_netfilter is missing (#684)
- On RHEL 10 / Rocky Linux 10 the default cloud image does not ship the
  kernel-modules-extra that contains br_netfilter, so modprobe br_netfilter
  fails during prereq on RedHat family
- Detect whether the module exists and install kernel-modules-extra when absent
- No reboot is required: the module is installed for the currently running kernel
  and becomes available to modprobe immediately
2026-08-03 14:55:07 +00:00
Techno Tim db85fa960c fix(metallb): guard cilium_bgp when undefined; clarify apiserver_endpoint docs (#683)
* fix(metallb): guard cilium_bgp variable before evaluating

- The 'Deploy metallb manifest' and 'Deploy metallb pool' conditionals evaluate
  'not cilium_bgp' directly, which raises an undefined-variable error when the
  k3s_server role runs without cilium_bgp in scope and no Cilium variables are set
- Guard with 'cilium_bgp is not defined' so the condition resolves cleanly when
  Cilium BGP is not configured
- Fixes #644

* docs(apiserver): clarify apiserver_endpoint must be a free routable IP

- Note that apiserver_endpoint must be an unassigned, routable IP on the
  network and that it is exposed by kube-vip / MetalLB
- Fixes #678

* fix(molecule): wait for MetalLB resources before asserting images

- The MetalLB image-tag assertion crashed with 'list object has no element 0'
  when the controller Deployment was not yet observable at verify time
- Retry the MetalLB controller/speaker lookup until the resources appear
- Fail with a clear message if MetalLB is genuinely absent
2026-08-03 04:52:27 +00:00
Techno Tim 6aea6e71b6 docs(agent): improve repository guidance [skip ci] (#685)
- Add canonical agent and contributor documentation.

- Modernize issue and pull request templates.

- Skip CI for documentation and issue-template-only changes.
2026-08-02 16:31:40 -05:00
Techno Timandfinaldoom 890d43b339 fix(token): escape percent signs used in k3s token (#682)
- systemd treats % as a specifier in ExecStart, so token values containing %
  fail with 'failed to resolve unit specifiers invalid slot'
- escape % to %% in the agent unit file
- escape % to %% in server_init_args so the multi-master bootstrap path
  (systemd-run k3s-init) handles % tokens too

Co-authored-by: finaldoom <677609+FinalDoom@users.noreply.github.com>
2026-08-02 19:27:17 +00:00
Techno Tim bb006cf157 feat(dependencies): upgrade supported cluster components (#681)
* fix(calico): support split CRDs for current releases

- Download the v1_crd_projectcalico_org.yaml bundle before the operator
- Apply both files with server-side apply and force-conflicts per the
  upstream upgrade procedure
- Wait for the operator Deployment and for the managed CRDs to be
  Established after the operator starts
- Replace the create/rescue/replace flow with an idempotent apply that
  no longer conceals partial failures
- Verify TigeraStatus for calico and apiserver is Available, not just
  that Pods exist

* feat(dependencies): upgrade supported cluster components

- Bump K3s to v1.36.2+k3s1, Calico to v3.32.1, Cilium to v1.20.0,
  kube-vip to v1.2.2, kube-vip cloud provider to v0.0.12, and MetalLB to
  v0.16.0 across sample inventory, role defaults, and argument specs
- Pin the Cilium CLI with a new cilium_cli_tag (v0.19.7) instead of the
  floating stable.txt lookup
- Replace the CiliumBGPPeeringPolicy v2alpha1 BGP template with the
  v2 CiliumBGPClusterConfig, CiliumBGPPeerConfig, CiliumBGPAdvertisement,
  and CiliumLoadBalancerIPPool resource set
- Move Cilium load balancer Helm keys from bpf.loadBalancer to the valid
  top-level loadBalancer path
- Add preflight schema validation and remove the deprecated policy after
  the v2 objects are accepted
- Wait for cilium status after installation
- Pin kube-vip RBAC in a repository template instead of fetching a
  mutable URL, and include EndpointSlice permissions
- Fix the kube-vip bgppeers format to address:ASN comma-separated peers
- Fail clearly when the MetalLB speaker tag replacement does not apply
- Drop the obsolete MetalLB webhook service name version branch

* test(molecule): verify upgraded cluster components

- Assert every node reports the expected K3s kubelet version
- Verify the active CNI (Flannel / Calico / Cilium) is Ready and runs
  the expected image tag, including Calico TigeraStatus Available
- Verify the active load balancer (MetalLB / kube-vip) runs the expected
  image tags and that MetalLB is absent when kube-vip is active
- Assert no Flannel DaemonSet remains when Calico or Cilium is enabled
- Assert the example LoadBalancer address falls inside the configured
  pool range
- Add a manifest-only Cilium BGP regression test that renders the v2
  template with zero, one, and multiple neighbors and rejects any v2alpha1
  or CiliumBGPPeeringPolicy output

* fix(dependencies): correct dependency version pins

- Set the sample kube-vip image to v1.2.2 and repair the damaged comment
- Pin the kube-vip cloud provider default to v0.0.12 in the task URL
- Set the MetalLB controller argument-spec default to v0.16.0
- Restore the MetalLB available timeout default to 240s

* docs(dependencies): document current cluster versions

- Update kube-vip, kube-vip cloud provider, and MetalLB defaults
- Add cilium_tag and cilium_cli_tag rows
- Explain that MetalLB v0.16.0 is the application image target even though
  a newer chart-only tag (metallb-chart-0.16.1) exists
- Add an existing-cluster upgrade warning covering the K3s etcd 3.5.26
  bridge and one-minor-at-a-time rule, consecutive Cilium minor upgrades,
  Calico v3 resource UID handling, and MetalLB app vs chart tags

* fix(dependencies): address PR review findings

- Read the MetalLB speaker tag check from the managed host with slurp
  instead of a controller-side file lookup, and match the full image
  reference
- Restore the tigera-operator namespace on the Calico operator Deployment
  wait while keeping the managed CRD waits cluster-scoped
- Make Molecule verify inputs durable and scenario-specific via a
  per-scenario verify-vars.yml, driven by explicit verify_cni/verify_lb
  values instead of non-persisted converge facts
- Rename the kube-vip multi-peer BGP env var from bgppeers to bgp_peers
  and vip_cidr to vip_subnet so v1.2.2 actually reads them
- Map the legacy Cilium routed mode to tunnel and stop passing the alias
  directly to the chart
- Use return-code based failed_when on apply and preflight commands so
  non-error failures are no longer treated as success
- Clarify the sequential K3s upgrade path and backups in the README
- Add kube-vip and MetalLB regression tests and a Cilium mode mapping unit

* fix(dependencies): resolve re-review findings

- correct the Calico TigeraStatus resource kind\n- document tunnel as the supported Cilium routing mode\n- validate load balancer addresses across range and CIDR pools

* fix(molecule): verify embedded flannel instead of a flannel DaemonSet

- K3s 1.36 runs flannel embedded in the k3s agent rather than as a
  kube-flannel-ds DaemonSet, so the flannel verifier queried a workload
  that no longer exists and failed the verify step
- For the flannel scenarios, assert every node is Ready and that neither
  the Calico nor the Cilium namespace exists
- Drop the now-invalid kube-flannel-ds DaemonSet assertion

* fix(molecule): wait for the LoadBalancer address before asserting reachability

- The nginx LoadBalancer service had no ingress address when the
  reachability assertion ran, so status.loadBalancer.ingress[0].ip was
  undefined and the ipwrap filter failed during verify
- Poll the service until MetalLB or kube-vip assigns an external IP
- Record the assigned address once and reuse it for the reachability probe
  and the pool membership checks

* fix(ci): harden calico apiserver wait and extend molecule job timeout

- Bump calico system resources wait retries 30->60 and delay 7->10 so the
  slow-to-reconcile calico-apiserver deployment has enough time under nested-virt
- Raise the molecule step timeout-minutes from 90 to 150 to accommodate
  contended 5-node scenarios (cilium, kube-vip) that were hitting the 90-min cap

* fix(calico): treat optional API server as best-effort on converge

- The Calico API server (calico-apiserver) is an optional add-on for managing
  Calico policy through the projectcalico.org/v3 Kubernetes API; it is not
  required for Calico CNI data plane operation
- With Calico v3.32.1 on K3s 1.36 the tigera-operator never provisions the
  calico-apiserver namespace, causing the converge wait to fail deterministically
- Keep the strict wait for core Calico components (typha, kube-controllers,
  calico-node, csi-node-driver) and make the API server wait tolerate failure
- Restrict the TigeraStatus Available check to the calico status, matching the
  upstream v3.32.1 K3s quickstart which validates without the API server
2026-08-02 12:44:50 -05:00
67 changed files with 3266 additions and 355 deletions

No files matched your search

+4
View File
@@ -15,6 +15,10 @@ exclude_paths:
- molecule/**/prepare.yml
- molecule/**/reset.yml
# Scenario verify inputs are plain variable files, not playbooks. They are
# loaded as vars, not executed, so ansible-lint must not treat them as plays.
- molecule/**/verify-vars.yml
# The file was generated by galaxy ansible - don't mess with it.
- galaxy.yml
-85
View File
@@ -1,85 +0,0 @@
<!-- It's a good idea to check this post first for general troubleshooting https://github.com/timothystewart6/k3s-ansible/discussions/19 -->
<!--- Provide a general summary of the issue in the Title above -->
## Expected Behavior
<!--- Tell us what should happen -->
## Current Behavior
<!--- Tell us what happens instead of the expected behavior -->
## Steps to Reproduce
<!--- reproduce this bug. Include code to reproduce, if relevant -->
1.
2.
3.
4.
## Context (variables)
<!--- please include which OS, along with the variables used when running the playbook -->
Operating system:
Hardware:
### Variables Used
`all.yml`
```yml
k3s_version: ""
ansible_user: NA
systemd_dir: ""
flannel_iface: ""
#calico_iface: ""
calico_ebpf: ""
calico_cidr: ""
calico_tag: ""
apiserver_endpoint: ""
k3s_token: "NA"
extra_server_args: ""
extra_agent_args: ""
kube_vip_tag_version: ""
kube_vip_cloud_provider_tag_version: ""
kube_vip_lb_ip_range: ""
metal_lb_speaker_tag_version: ""
metal_lb_controller_tag_version: ""
metal_lb_ip_range: ""
```
### Hosts
`host.ini`
```ini
[master]
IP.ADDRESS.ONE
IP.ADDRESS.TWO
IP.ADDRESS.THREE
[node]
IP.ADDRESS.FOUR
IP.ADDRESS.FIVE
[k3s_cluster:children]
master
node
```
## Possible Solution
<!--- Not obligatory, but suggest a fix/reason for the bug, -->
- [ ] I've checked the [General Troubleshooting Guide](https://github.com/timothystewart6/k3s-ansible/discussions/20)
+102
View File
@@ -0,0 +1,102 @@
---
name: Bug report
description: Report a reproducible problem with the playbooks, roles, or generated resources.
title: "[Bug]: "
labels:
- bug
body:
- type: markdown
attributes:
value: |
Thanks for reporting a problem. Search existing issues and review the troubleshooting link first.
Remove credentials, tokens, public IP addresses, and private hostnames from all fields and logs.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I searched existing issues and discussions for this problem.
required: true
- label: I reviewed the troubleshooting guidance linked from the issue chooser.
required: true
- label: I removed secrets and identifying infrastructure details from this report.
required: true
- type: textarea
id: summary
attributes:
label: Problem summary
description: Describe what failed and its impact.
placeholder: A concise description of the problem and affected nodes or components.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: What should have happened?
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to reproduce
description: Provide the smallest reliable sequence that reproduces the issue.
placeholder: |
1. Configure ...
2. Run ...
3. Observe ...
validations:
required: true
- type: input
id: revision
attributes:
label: Repository revision
description: Release, tag, branch, or commit SHA used.
placeholder: v1.36.2+k3s1+tt1 or a commit SHA
validations:
required: true
- type: input
id: ansible-version
attributes:
label: Ansible version
description: Output of `ansible --version`, shortened to version and Python details.
placeholder: ansible-core 2.18.0, Python 3.12
validations:
required: true
- type: textarea
id: environment
attributes:
label: Environment
description: Include target OS and version, architecture, node counts, platform, and network provider.
placeholder: Debian 13, amd64, 3 control nodes and 2 agents, bare metal, Cilium
validations:
required: true
- type: textarea
id: configuration
attributes:
label: Relevant sanitized configuration
description: Include only variables and inventory groups needed to reproduce the problem.
render: yaml
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant logs or task output
description: Include the failing task and surrounding output. Redact sensitive or identifying values.
render: shell
- type: textarea
id: context
attributes:
label: Additional context
description: Add attempted fixes, suspected causes, regressions, or other useful context.
+9
View File
@@ -0,0 +1,9 @@
---
blank_issues_enabled: false
contact_links:
- name: Troubleshooting and support
url: https://github.com/timothystewart6/k3s-ansible/discussions/20
about: Review common troubleshooting guidance and ask configuration or usage questions.
- name: General discussions
url: https://github.com/timothystewart6/k3s-ansible/discussions
about: Discuss ideas and questions that are not confirmed bugs or concrete feature requests.
@@ -0,0 +1,55 @@
---
name: Feature request
description: Propose a focused improvement to supported repository behavior.
title: "[Feature]: "
labels:
- enhancement
body:
- type: markdown
attributes:
value: |
Describe the use case before proposing an implementation. Search existing issues and discussions first.
- type: checkboxes
id: prerequisites
attributes:
label: Prerequisites
options:
- label: I searched existing issues and discussions for this request.
required: true
- label: This request is about reusable project behavior, not support for one private environment.
required: true
- type: textarea
id: problem
attributes:
label: Problem or use case
description: What limitation exists, who encounters it, and why does it matter?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposed behavior
description: Describe the desired user-visible result. Include example variables or commands when useful.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: Describe workarounds or other designs and their tradeoffs.
- type: textarea
id: compatibility
attributes:
label: Compatibility and operational impact
description: Note affected operating systems, architectures, CNIs, existing clusters, or reset behavior.
- type: textarea
id: context
attributes:
label: Additional context
description: Add relevant upstream documentation, examples, or prior discussion.
+42 -11
View File
@@ -1,15 +1,46 @@
# Proposed Changes
<!--- Provide a general summary of your changes -->
## Summary
<!-- Explain the problem and the resulting behavior. Keep implementation details in the sections below. -->
## Changes
-
-
-
## Checklist
## Related issues
- [ ] Tested locally
- [ ] Ran `site.yml` playbook
- [ ] Ran `reset.yml` playbook
- [ ] Did not add any unnecessary changes
- [ ] Ran pre-commit install at least once before committing
- [ ] 🚀
<!-- Use "Fixes #123" when this pull request should close an issue. Write "None" when not applicable. -->
## Testing
<!-- List exact commands, scenarios, and relevant manual checks. Do not check a box for a test that was not run. -->
- [ ] `pre-commit run --all-files`
- [ ] Relevant Ansible syntax checks
- [ ] Relevant focused regression tests
- [ ] Relevant Molecule scenario
- [ ] Provisioning tested against a non-production cluster
- [ ] Reset behavior tested against a non-production cluster
Not run, with reason:
## Risk and compatibility
<!-- Cover existing clusters, upgrades, networking, supported platforms, security, and rollback. Write "None" when a
category is not affected. -->
- Existing cluster or upgrade impact:
- Networking or CNI impact:
- Security impact:
- Rollback plan:
## Documentation
<!-- Identify updated docs and sample configuration, or explain why no documentation change is needed. -->
## Final checklist
- [ ] The change is focused and contains no unrelated edits.
- [ ] Tests cover new behavior or a regression, where applicable.
- [ ] User-facing variables are documented in role defaults, sample inventory, and the README.
- [ ] Logs, examples, and configuration contain no secrets or identifying infrastructure details.
- [ ] Generated files and local environment files are not included.
+6
View File
@@ -0,0 +1,6 @@
# GitHub Copilot instructions
Read and follow the repository's root-level `AGENTS.md` before proposing or making changes. It is the canonical guide
for architecture, safety, implementation, validation, and documentation expectations.
Do not duplicate repository guidance here. If instructions need to change, update `AGENTS.md`.
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""Render the Cilium BGP CRD template and assert it uses the v2 API.
This is a manifest-only regression test used where no real BGP peer is
available. It renders roles/k3s_server_post/templates/cilium.crs.j2 with
zero, one, and multiple neighbors, then checks that the output:
- never contains CiliumBGPPeeringPolicy or cilium.io/v2alpha1
- emits the Cilium v2 BGP resources
- emits deterministic DNS-safe peer and instance names
- advertises Pod CIDRs only when cilium_exportPodCIDR is true
"""
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("Cilium BGP manifest test failed: " + message)
def render(env, extra_vars):
base_vars = {
"cilium_bgp_my_asn": "64513",
"cilium_bgp_peer_asn": "64512",
"cilium_bgp_peer_address": "192.168.30.1",
"cilium_exportPodCIDR": True,
"cilium_bgp_lb_cidr": "192.168.31.0/24",
}
base_vars.update(extra_vars)
template = env.get_template("cilium.crs.j2")
return template.render(**base_vars)
def check_common(output):
if "cilium.io/v2alpha1" in output:
fail("rendered output still contains cilium.io/v2alpha1")
if "kind: CiliumBGPPeeringPolicy" in output:
fail("rendered output still contains CiliumBGPPeeringPolicy")
for kind in (
"CiliumBGPPeerConfig",
"CiliumBGPClusterConfig",
"CiliumBGPAdvertisement",
"CiliumLoadBalancerIPPool",
):
if ("kind: " + kind) not in output:
fail("rendered output is missing kind: " + kind)
def main():
root = repo_root()
template_dir = os.path.join(
root, "roles", "k3s_server_post", "templates"
)
env = Environment(
loader=FileSystemLoader(template_dir), undefined=StrictUndefined
)
# Zero neighbors -> fall back to the single default peer.
output = render(env, {"_cilium_bgp_neighbors": []})
check_common(output)
if "peer-64512-1" not in output:
fail("default single peer name was not rendered")
if "peerAddress: 192.168.30.1" not in output:
fail("default peer address was not rendered")
if 'advertisementType: "PodCIDR"' not in output:
fail("PodCIDR advertisement missing when exportPodCIDR is true")
# One neighbor via the merged list.
output = render(
env,
{"_cilium_bgp_neighbors": [{"peer_address": "10.0.0.1", "peer_asn": "65001"}]},
)
check_common(output)
if "peer-65001-1" not in output:
fail("single merged peer name was not rendered")
if "peerAddress: 10.0.0.1" not in output:
fail("single merged peer address was not rendered")
# Multiple neighbors.
output = render(
env,
{
"_cilium_bgp_neighbors": [
{"peer_address": "10.0.0.1", "peer_asn": "65001"},
{"peer_address": "10.0.0.2", "peer_asn": "65002"},
]
},
)
check_common(output)
if "peer-65001-1" not in output or "peer-65002-2" not in output:
fail("multiple merged peer names were not rendered")
if "peerAddress: 10.0.0.2" not in output:
fail("second merged peer address was not rendered")
# exportPodCIDR false -> no PodCIDR advertisement, service remains.
output = render(
env, {"_cilium_bgp_neighbors": [], "cilium_exportPodCIDR": False}
)
check_common(output)
if 'advertisementType: "PodCIDR"' in output:
fail("PodCIDR advertisement present when exportPodCIDR is false")
if 'advertisementType: "Service"' not in output:
fail("Service advertisement missing when exportPodCIDR is false")
# Load balancer pools: CIDR and start/stop forms.
output = render(env, {"_cilium_bgp_neighbors": []})
if "cidr: 192.168.31.0/24" not in output:
fail("CIDR load balancer pool was not rendered")
output = render(
env,
{
"_cilium_bgp_neighbors": [],
"cilium_bgp_lb_cidr": "192.168.31.80-192.168.31.90",
},
)
check_common(output)
if "start: 192.168.31.80" not in output or "stop: 192.168.31.90" not in output:
fail("start/stop load balancer pool was not rendered")
print("Cilium BGP manifest regression test passed")
if __name__ == "__main__":
main()
+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()
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Assert the sample inventory resolves the flannel interface per host.
flannel_iface defaults to the host's default IPv4 interface rather than a
hardcoded eth0. This test extracts the flannel_iface expression from the
sample inventory and proves that a host whose primary interface is not named
eth0 (e.g. enp1s0, ens3) resolves the interface from ansible facts.
"""
from __future__ import print_function
import os
import re
import subprocess
from jinja2 import Environment, StrictUndefined
def repo_root():
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
def fail(message):
raise SystemExit("default-interface test failed: " + message)
class FakeAnsibleFacts(object):
"""Stand-in for the per-host ``ansible_facts`` dict."""
def __init__(self, default_iface, iface_ip):
self._default = {"interface": default_iface, "address": iface_ip}
self._ifaces = {
default_iface: {"ipv4": {"address": iface_ip}},
}
@property
def default_ipv4(self):
return self._default
def __getitem__(self, key):
return self._ifaces[key]
def read_all_yml(root):
path = os.path.join(root, "inventory", "sample", "group_vars", "all.yml")
with open(path, "r") as handle:
return handle.read()
def extract_value(content, key):
# Match a quoted value assigned to the key, e.g. flannel_iface: "...".
match = re.search(r"^%s:\s*\"(.+)\"\s*$" % re.escape(key), content, re.M)
if not match:
fail("could not find %s in the sample inventory" % key)
return match.group(1)
def resolve(env, expression, facts):
template = env.from_string(expression)
return template.render(ansible_facts=facts)
def main():
root = repo_root()
content = read_all_yml(root)
env = Environment(undefined=StrictUndefined)
flannel_expr = extract_value(content, "flannel_iface")
if "default_ipv4.interface" not in flannel_expr:
fail("flannel_iface no longer defaults from ansible facts")
# A host whose primary interface is enp1s0 (the core #621 scenario).
facts = FakeAnsibleFacts("enp1s0", "192.168.30.11")
resolved = resolve(env, flannel_expr, facts)
if resolved != "enp1s0":
fail("flannel_iface resolved to %r, expected enp1s0" % resolved)
# A different host with a different interface must resolve independently.
facts2 = FakeAnsibleFacts("ens3", "192.168.30.12")
resolved2 = resolve(env, flannel_expr, facts2)
if resolved2 != "ens3":
fail("flannel_iface resolved to %r, expected ens3" % resolved2)
print("default-interface regression test passed")
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repo_root="$(git rev-parse --show-toplevel)"
prereq_defaults="$repo_root/roles/prereq/defaults/main.yml"
prereq_tasks="$repo_root/roles/prereq/tasks/main.yml"
# #670: k3s recommends swap be disabled on all nodes. The prereq role must expose
# a disable_swap toggle (defaulting to true) that turns swap off now and comments
# out the /etc/fstab swap entries so swap stays off across reboots.
grep -Eq -- '^disable_swap: true' "$prereq_defaults" || {
printf 'prereq defaults are missing disable_swap: true\n' >&2
exit 1
}
grep -Fq -- 'Disable swap on all cluster nodes' "$prereq_tasks" || {
printf 'prereq tasks are missing the swap-disable block\n' >&2
exit 1
}
grep -Fq -- 'swapoff -a' "$prereq_tasks" || {
printf 'swap-disable block does not run swapoff -a\n' >&2
exit 1
}
grep -Fq -- '/etc/fstab' "$prereq_tasks" || {
printf 'swap-disable block does not comment out /etc/fstab swap entries\n' >&2
exit 1
}
if ! grep -Eq -- 'when: disable_swap' "$prereq_tasks"; then
printf 'swap-disable block is not gated on the disable_swap toggle\n' >&2
exit 1
fi
printf 'Swap disable regression test passed\n'
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Assert the k3s-init log destination guard skips when unset.
``roles/k3s_server/tasks/main.yml`` fetches k3s-init diagnostics only when
``ANSIBLE_K3S_LOG_DIR`` is set. The lookup default must be an empty string:
Jinja stringifies the boolean ``False`` to ``'False'``, which is non-empty, so
a boolean default makes the guard pass and the role creates a directory
literally named ``False`` in the operator's working directory on every run.
This test extracts the real ``log_destination`` expression from the role and
renders it for both the unset and set cases.
"""
from __future__ import print_function
import os
import re
import subprocess
import yaml
from jinja2 import Environment
ROLE = os.path.join("roles", "k3s_server", "tasks", "main.yml")
def repo_root():
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
def fail(message):
raise SystemExit("k3s-init log destination test failed: " + message)
def find_log_task(root):
"""Return the 'Save logs of k3s-init.service' task from the role."""
with open(os.path.join(root, ROLE), "r") as handle:
tasks = yaml.safe_load(handle)
def walk(entries):
for entry in entries or []:
if not isinstance(entry, dict):
continue
if "Save logs of k3s-init.service" in str(entry.get("name", "")):
return entry
for key in ("block", "always", "rescue"):
found = walk(entry.get(key))
if found:
return found
return None
task = walk(tasks)
if task is None:
fail("could not find the 'Save logs of k3s-init.service' task in " + ROLE)
return task
def env_lookup(name, default=None, **kwargs):
"""Stand-in for the env lookup: unset names fall back to the default."""
return {"ANSIBLE_K3S_LOG_DIR": None}.get(name, default)
def render(task, env, value):
"""Render log_destination the way Ansible folds a '>-' scalar."""
expression = str(task["vars"]["log_destination"]).strip()
template = env.from_string(expression)
def lookup(*args, **kwargs):
name = args[1] if len(args) > 1 else kwargs.get("name")
if name != "ANSIBLE_K3S_LOG_DIR":
return kwargs.get("default")
return kwargs["default"] if value is None else value
return template.render(lookup=lookup).strip()
def main():
root = repo_root()
task = find_log_task(root)
env = Environment()
unset = render(task, env, None)
if unset != "":
fail(
"log_destination resolved to %r when ANSIBLE_K3S_LOG_DIR is unset; "
"it must be an empty string so the guard skips" % unset
)
if task["when"].strip() == "":
fail("the guard on the log task is empty, so it would always run")
set_path = render(task, env, "/var/log/k3s-init")
if set_path != "/var/log/k3s-init":
fail(
"log_destination resolved to %r when ANSIBLE_K3S_LOG_DIR is set; "
"expected the path to be passed through unchanged" % set_path
)
# A stray default= would leave the boolean in place even though the
# expression still renders empty, so assert the source no longer carries
# it. This is the exact regression that produced the False/ directory.
if re.search(r"default\s*=\s*False", yaml.safe_dump(task)):
fail(
"log_destination still uses a boolean default; Jinja would "
"stringify it to 'False' and create a directory named False"
)
print("k3s-init log destination regression test passed")
if __name__ == "__main__":
main()
+1 -1
View File
@@ -26,7 +26,7 @@ if grep -Fq -- "node-role.kubernetes.io/master=true' -o=jsonpath" "$main_tasks";
exit 1
fi
grep -Fq -- "map('extract', hostvars, 'ansible_hostname')" "$main_tasks"
grep -Fq -- "map('extract', hostvars, ['ansible_facts', 'hostname'])" "$main_tasks"
grep -Fq -- 'difference(nodes.stdout.split())' "$main_tasks"
grep -Fq -- 'crd/addons.k3s.cattle.io' "$main_tasks"
grep -Fq -- 'crd/helmcharts.helm.cattle.io' "$main_tasks"
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Regression test for the kube-vip deploy conditions.
The control-plane VIP (roles/k3s_server/tasks/vip.yml) and the kube-vip
service load balancer (roles/k3s_server/tasks/kube-vip.yml) are both included
from roles/k3s_server/tasks/main.yml. Before this switch existed the VIP
include had no gate and always ran, so a user who wanted no kube-vip (single
node or external LB) could not opt out.
This test loads the real `when` expressions from the k3s_server task file and
evaluates them against representative variable sets, asserting that:
- the control-plane VIP is deployed only when kube_vip_enabled is true;
- the service load balancer is deployed only when kube_vip_enabled is true
AND kube_vip_lb_ip_range is defined.
"""
from __future__ import print_function
import os
import re
import subprocess
import yaml
from jinja2 import Environment
VIP_WHEN = "kube_vip_enabled"
KUBE_VIP_WHEN = "kube_vip_enabled and kube_vip_lb_ip_range is defined"
def repo_root():
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
def fail(message):
raise SystemExit("kube-vip deploy condition test failed: " + message)
def extract_when(path, task_name):
"""Return the `when:` expression string for the named task."""
with open(path, encoding="utf-8") as handle:
doc = yaml.safe_load(handle)
for task in doc:
if task.get("name") == task_name:
when = task.get("when")
return (when or "").strip()
return None
def evaluate(when, variables):
"""Evaluate a `when` expression against variables using Jinja2."""
env = Environment()
def fake_bool(value):
# Minimal stand-in for Ansible's truthiness filter used by `| bool`.
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).lower() in ("1", "true", "yes", "on")
env.filters["bool"] = fake_bool
template = env.from_string("{{ " + when + " }}")
rendered = template.render(**variables)
# The expression renders to the literal strings "True"/"False".
if rendered == "True":
return True
if rendered == "False":
return False
fail("condition did not render to a boolean: {0!r}".format(rendered))
def assert_deployment(when, variables, expected, label):
result = evaluate(when, variables)
verdict = "deploy" if result else "skip"
expected_verdict = "deploy" if expected else "skip"
if result != expected:
fail(
"{0}: expected to {1} but the condition chose to {2} "
"(vars: {3})".format(label, expected_verdict, verdict, variables)
)
def scenarios():
"""Yield (variables, expected_vip, expected_service_lb, label) pairs."""
yield (
# Default inventory: kube-vip enabled, no service LB range.
{
"kube_vip_enabled": True,
},
True,
False,
"default: kube-vip VIP only",
)
yield (
# kube-vip owns the service LB range too.
{
"kube_vip_enabled": True,
"kube_vip_lb_ip_range": "192.168.30.80-192.168.30.90",
},
True,
True,
"kube-vip VIP and service LB",
)
yield (
# Explicitly disabled, no LB range.
{
"kube_vip_enabled": False,
},
False,
False,
"kube_vip_enabled: false",
)
yield (
# Explicitly disabled even when the LB range is present.
{
"kube_vip_enabled": False,
"kube_vip_lb_ip_range": "192.168.30.80-192.168.30.90",
},
False,
False,
"kube_vip_enabled: false despite LB range",
)
def main():
root = repo_root()
server_tasks = os.path.join(root, "roles", "k3s_server", "tasks", "main.yml")
vip_when = extract_when(server_tasks, "Deploy vip manifest")
kube_vip_when = extract_when(server_tasks, "Deploy kube-vip manifest")
if vip_when is None:
fail("could not find 'Deploy vip manifest' when condition")
if kube_vip_when is None:
fail("could not find 'Deploy kube-vip manifest' when condition")
if vip_when != VIP_WHEN:
fail(
"k3s_server/tasks/main.yml 'Deploy vip manifest' when condition "
"changed unexpectedly:\n"
" expected: {0}\n got: {1}".format(VIP_WHEN, vip_when)
)
if kube_vip_when != KUBE_VIP_WHEN:
fail(
"k3s_server/tasks/main.yml 'Deploy kube-vip manifest' when "
"condition changed unexpectedly:\n"
" expected: {0}\n got: {1}".format(KUBE_VIP_WHEN, kube_vip_when)
)
for variables, expected_vip, expected_service_lb, label in scenarios():
assert_deployment(vip_when, variables, expected_vip, "vip " + label)
assert_deployment(
kube_vip_when,
variables,
expected_service_lb,
"service_lb " + label,
)
print("kube-vip deploy condition regression test passed for all scenarios")
if __name__ == "__main__":
main()
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env python3
"""Render the kube-vip DaemonSet template and assert env key correctness.
kube-vip v1.2.3 reads `bgp_peers` and `vip_subnet`; it ignores the older
`bgppeers` and `vip_cidr` names. This test proves the rendered manifest uses
the keys the target image actually parses.
"""
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("kube-vip manifest test failed: " + message)
def fake_ipsubnet(value):
# ansible.utils.ipsubnet -> network of the address as x.y.z.0/24
parts = value.split(".")
return ".".join(parts[:3]) + ".0/24"
def fake_ipaddr(_value, expr=None):
# ansible.utils.ipaddr('prefix') -> prefix length
return "24"
def fake_bool(value):
# Minimal stand-in for Ansible's truthiness filter used by the template.
if isinstance(value, bool):
return value
return str(value).lower() in ("1", "true", "yes", "on")
def fake_map(seq, *args, **kwargs):
# Minimal stand-in for Ansible's map() filter in the two forms used by the
# template: map(attribute='x') on a list of dicts, and map('join', sep) on
# a list of sequences.
if "attribute" in kwargs:
return [item[kwargs["attribute"]] for item in seq]
if kwargs:
# e.g. map(default='x') not used here; ignore unknown kwargs.
return list(seq)
if args:
filter_name = args[0]
sep = args[1] if len(args) > 1 else ""
if filter_name == "join":
return [sep.join(str(x) for x in item) for item in seq]
return list(seq)
def fake_zip(*seqs):
return list(zip(*seqs))
def render(env, extra_vars):
base_vars = {
"apiserver_endpoint": "192.168.30.222",
"kube_vip_iface": "",
"kube_vip_arp": True,
"kube_vip_bgp": True,
"kube_vip_bgp_routerid": "127.0.0.1",
"_kube_vip_bgp_peers": [
{"peer_address": "192.168.30.1", "peer_asn": "64512"},
{"peer_address": "192.168.30.2", "peer_asn": "64513"},
],
"kube_vip_tag_version": "v1.2.3",
}
base_vars.update(extra_vars)
template = env.get_template("vip.yaml.j2")
return template.render(**base_vars)
def main():
root = repo_root()
template_dir = os.path.join(root, "roles", "k3s_server", "templates")
env = Environment(
loader=FileSystemLoader(template_dir), undefined=StrictUndefined
)
env.filters["ansible.utils.ipsubnet"] = fake_ipsubnet
env.filters["ansible.utils.ipaddr"] = fake_ipaddr
env.filters["bool"] = fake_bool
env.filters["map"] = fake_map
env.filters["zip"] = fake_zip
# Multi-peer BGP armed: must emit bgp_peers, never bgppeers.
output = render(env, {})
if "name: bgp_peers" not in output:
fail("rendered manifest is missing bgp_peers")
if "name: bgppeers" in output:
fail("rendered manifest still uses the ignored bgppeers key")
if "name: vip_subnet" not in output:
fail("rendered manifest is missing vip_subnet")
if "name: vip_cidr" in output:
fail("rendered manifest still uses the ignored vip_cidr key")
if "192.168.30.1:64512,192.168.30.2:64513" not in output:
fail("bgp_peers value is not comma-separated address:ASN entries")
if "ghcr.io/kube-vip/kube-vip:v1.2.3" not in output:
fail("kube-vip image tag is not v1.2.3")
# BGP enabled with no merged peers: single-peer fallback vars, no bgp_peers.
output = render(
env,
{
"_kube_vip_bgp_peers": [],
"kube_vip_bgp_as": "64513",
"kube_vip_bgp_peeraddress": "192.168.30.1",
"kube_vip_bgp_peeras": "64512",
},
)
if "name: bgp_as" not in output:
fail("single-peer bgp_as was not rendered")
if "name: bgp_peers" in output:
fail("bgp_peers present even though the peer list is empty")
# kube_vip_endpoint defaults to null (defined in role defaults): the
# address and subnet must fall back to the apiserver endpoint. default()
# without a truthy flag does NOT fall back on null, only on undefined, so
# this case pins the null runtime condition to prevent that regression.
output = render(
env,
{
"_kube_vip_bgp_peers": [],
"kube_vip_endpoint": None,
"kube_vip_arp": True,
"kube_vip_bgp": False,
},
)
if "value: 192.168.30.222" not in output:
fail("null kube_vip_endpoint does not fall back to apiserver_endpoint")
# kube_vip_endpoint set: overrides the internal listening address AND the
# subnet derivation while the advertised apiserver_endpoint stays separate.
output = render(
env,
{
"_kube_vip_bgp_peers": [],
"kube_vip_endpoint": "10.66.1.5",
"kube_vip_arp": True,
"kube_vip_bgp": False,
},
)
if "value: 10.66.1.5" not in output:
fail("kube_vip_endpoint did not override the address")
if "value: 192.168.30.222" in output:
fail("apiserver_endpoint leaked into address when kube_vip_endpoint set")
print("kube-vip manifest regression test passed")
if __name__ == "__main__":
main()
@@ -0,0 +1,185 @@
#!/usr/bin/env python3
"""Regression test for the MetalLB deploy conditions.
The MetalLB manifest (roles/k3s_server/tasks/main.yml) and the MetalLB pool
(roles/k3s_server_post/tasks/main.yml) are included under a `when` condition
that decides whether MetalLB provides load balancing. A previous change (#683)
guarded `cilium_bgp` but accidentally skipped MetalLB whenever a non-BGP
Cilium CNI was in use (`cilium_iface` defined), breaking the cilium + MetalLB
scenario.
This test loads the real `when` expressions from both task files and evaluates
them against representative variable sets, asserting MetalLB is deployed in
every topology except when MetalLB is explicitly disabled, kube-vip owns the
VIP range, or Cilium BGP is enabled.
"""
from __future__ import print_function
import os
import re
import subprocess
import yaml
from jinja2 import Environment
METALLB_WHEN = (
"metal_lb_enabled and "
"kube_vip_lb_ip_range is not defined and "
"not (cilium_bgp | default(false) | bool)"
)
def repo_root():
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
def fail(message):
raise SystemExit("MetalLB deploy condition test failed: " + message)
def extract_when(path, task_name):
"""Return the `when:` expression string for the named task."""
with open(path, encoding="utf-8") as handle:
doc = yaml.safe_load(handle)
for task in doc:
if task.get("name") == task_name:
when = task.get("when")
return (when or "").strip()
return None
def evaluate(when, variables):
"""Evaluate a `when` expression against variables using Jinja2."""
env = Environment()
def fake_bool(value):
# Minimal stand-in for Ansible's truthiness filter used by `| bool`.
if isinstance(value, bool):
return value
if value is None:
return False
return str(value).lower() in ("1", "true", "yes", "on")
env.filters["bool"] = fake_bool
template = env.from_string("{{ " + when + " }}")
rendered = template.render(**variables)
# The expression renders to the literal strings "True"/"False".
if rendered == "True":
return True
if rendered == "False":
return False
fail("condition did not render to a boolean: {0!r}".format(rendered))
def assert_deployment(when, variables, expected, label):
result = evaluate(when, variables)
verdict = "deploy" if result else "skip"
expected_verdict = "deploy" if expected else "skip"
if result != expected:
fail(
"{0}: expected to {1} MetalLB but the condition chose to {2} "
"(vars: {3})".format(label, expected_verdict, verdict, variables)
)
def scenarios():
"""Yield (variables, expected_deploy, label) pairs."""
yield (
# Default Flannel inventory (all.yml sets cilium_bgp: false).
{
"metal_lb_enabled": True,
"cilium_bgp": False,
"cilium_iface": None,
},
True,
"flannel default (cilium_bgp: false)",
)
yield (
# Calico CNI with no Cilium variable in scope (issue #644): cilium_bgp
# is genuinely undefined, so `default(false)` must keep MetalLB on.
{
"metal_lb_enabled": True,
"calico_iface": "eth1",
},
True,
"calico, cilium_bgp undefined (#644)",
)
yield (
# Cilium CNI with BGP disabled: MetalLB must still be deployed.
{
"metal_lb_enabled": True,
"cilium_bgp": False,
"cilium_iface": "eth1",
},
True,
"cilium non-BGP (regression catch)",
)
yield (
# Cilium CNI with BGP enabled: Cilium provides the LB, skip MetalLB.
{
"metal_lb_enabled": True,
"cilium_bgp": True,
"cilium_iface": "eth1",
},
False,
"cilium BGP enabled",
)
yield (
# kube-vip is the load balancer provider: skip MetalLB.
{
"metal_lb_enabled": True,
"kube_vip_lb_ip_range": "192.168.30.80-192.168.30.90",
"cilium_bgp": False,
},
False,
"kube-vip owns the VIP range",
)
yield (
# MetalLB explicitly disabled (external LB / single node): skip.
{
"metal_lb_enabled": False,
},
False,
"metal_lb_enabled: false (external LB)",
)
def main():
root = repo_root()
server_tasks = os.path.join(root, "roles", "k3s_server", "tasks", "main.yml")
server_post_tasks = os.path.join(
root, "roles", "k3s_server_post", "tasks", "main.yml"
)
server_when = extract_when(server_tasks, "Deploy metallb manifest")
server_post_when = extract_when(server_post_tasks, "Deploy metallb pool")
if server_when is None:
fail("could not find 'Deploy metallb manifest' when condition")
if server_post_when is None:
fail("could not find 'Deploy metallb pool' when condition")
for when, source in (
(server_when, "k3s_server/tasks/main.yml"),
(server_post_when, "k3s_server_post/tasks/main.yml"),
):
if when != METALLB_WHEN:
fail(
"{0} when condition changed unexpectedly:\n"
" expected: {1}\n got: {2}".format(source, METALLB_WHEN, when)
)
for variables, expected, label in scenarios():
assert_deployment(server_when, variables, expected, "server " + label)
assert_deployment(
server_post_when, variables, expected, "server_post " + label
)
print("MetalLB deploy condition regression test passed for all scenarios")
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()
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Regression test for the MetalLB converge checks.
The MetalLB tasks in roles/k3s_server_post/tasks/metallb.yml must actually
verify resources through an explicit kubectl get, and must retry on a
transient kube API error while MetalLB converges.
The "Test metallb-system namespace" task previously ran `k3s kubectl -n
metallb-system` with no subcommand, which only printed a usage page and always
exited 0, so it always succeeded even when the namespace did not exist (issue
#350). It must instead run an explicit `get namespace metallb-system`, which
returns non-zero when the namespace is absent.
An explicit get actually contacts the API server, so these tasks need the same
retry wiring as their siblings (register, until rc == 0, retries, delay). A
bare get with no retry would otherwise abort the converge play on a transient
kube API error while MetalLB converges.
"""
from __future__ import print_function
import os
import subprocess
import yaml
def repo_root():
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
def fail(message):
raise SystemExit("MetalLB namespace test failed: " + message)
def find_task(tasks, name):
for entry in tasks:
if entry.get("name") == name:
return entry
fail("could not find the '{0}' task".format(name))
return None
def command_text(task):
cmd = task.get("ansible.builtin.command")
if not cmd:
cmd = task.get("command")
if not cmd:
fail("task does not use ansible.builtin.command")
return cmd if isinstance(cmd, str) else " ".join(cmd)
def check_explicit_get(task, name, needle):
text = command_text(task)
if needle not in text:
fail(
"command does not run '{0}'; the task would only print usage and "
"never verify the resource (got: {1!r})".format(needle, text)
)
def check_retry_wiring(task, name):
# The sibling k3s_server_post metallb tasks retry kubectl because the kube
# API can briefly be unavailable while MetalLB converges. Without the same
# retry, a transient API error aborts the whole converge play.
if not task.get("register"):
fail(
"{0} does not register a result; without retry wiring a transient "
"kube API error aborts the converge play".format(name)
)
if not isinstance(task.get("until"), str) or "rc == 0" not in task["until"]:
fail(
"{0} does not retry on rc == 0; the kube API can transiently fail "
"while MetalLB converges and abort the play".format(name)
)
if task.get("retries") is None:
fail("{0} is missing retries".format(name))
if task.get("delay") is None:
fail("{0} is missing delay".format(name))
def main():
task_file = os.path.join(
repo_root(), "roles", "k3s_server_post", "tasks", "metallb.yml"
)
with open(task_file, encoding="utf-8") as handle:
tasks = yaml.safe_load(handle)
namespace_task = find_task(tasks, "Test metallb-system namespace")
# A bare `-n metallb-system` with no subcommand prints kubectl usage and
# always exits 0, so it never proves the namespace exists. The fix must
# use an explicit get.
check_explicit_get(namespace_task, "Test metallb-system namespace",
"get namespace metallb-system")
check_retry_wiring(namespace_task, "Test metallb-system namespace")
webhook_task = find_task(tasks, "Test metallb-system webhook-service endpoint")
check_explicit_get(webhook_task, "Test metallb-system webhook-service endpoint",
"get endpoints")
check_retry_wiring(webhook_task, "Test metallb-system webhook-service endpoint")
print("MetalLB namespace check regression test passed")
if __name__ == "__main__":
main()
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repo_root="$(git rev-parse --show-toplevel)"
metallb_task="$repo_root/roles/k3s_server/tasks/metallb.yml"
# The speaker tag verification must read the rendered manifest on the managed
# host with slurp. A controller-side lookup('ansible.builtin.file', ...) would
# read from the Ansible control node, which does not have the file, and would
# fail on every MetalLB scenario.
grep -Fq -- 'ansible.builtin.slurp' "$metallb_task" || {
printf 'MetalLB speaker tag check does not use slurp on the managed host\n' >&2
exit 1
}
grep -Eq -- 'lookup\(.?ansible\.builtin\.file' "$metallb_task" && {
printf 'MetalLB speaker tag check uses a controller-side file lookup\n' >&2
exit 1
}
# The check must reference the full image reference, not just a bare version
# string that could appear anywhere in the manifest.
grep -Fq -- 'quay.io/metallb/speaker:' "$metallb_task" || {
printf 'MetalLB speaker tag check does not match the full image reference\n' >&2
exit 1
}
printf 'MetalLB remote manifest read regression test passed\n'
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -Eeuo pipefail
repo_root="$(git rev-parse --show-toplevel)"
tasks_file="$repo_root/roles/raspberrypi/tasks/main.yml"
if [ ! -f "$tasks_file" ]; then
printf 'cannot find %s\n' "$tasks_file" >&2
exit 1
fi
# The role decides whether a host is a Raspberry Pi from the return code of two
# ansible.builtin.command probes. The command module never executes under
# --check: it returns rc 0 with "Command would have run if not in check mode".
# Without check_mode: false on both probes every host looks like a Raspberry Pi
# in a check run, so distro specific tasks (e.g. the Ubuntu cgroup
# cmdline.txt edit) run against hosts that must not be touched.
probe_count="$(grep -c -- 'check_mode: false' "$tasks_file" || true)"
if [ "$probe_count" -lt 2 ]; then
printf 'raspberrypi detection probes must set check_mode: false (found %s, expected 2)\n' \
"$probe_count" >&2
exit 1
fi
# Both probes must be the detection greps, not some other task.
for probe in \
'grep -E "Raspberry Pi|BCM2708|BCM2709|BCM2835|BCM2836" /proc/cpuinfo' \
'grep -E "Raspberry Pi" /proc/device-tree/model'; do
grep -Fq -- "$probe" "$tasks_file" || {
printf 'missing raspberry pi detection probe: %s\n' "$probe" >&2
exit 1
}
done
# The probes must stay registered under the names the gate consumes.
for var in grep_cpuinfo_raspberrypi grep_device_tree_model_raspberrypi; do
grep -Fq -- "register: $var" "$tasks_file" || {
printf 'detection probe is not registered as %s\n' "$var" >&2
exit 1
}
done
# The gate must keep keying on rc 0 and must tolerate a missing rc so an
# undefined result cannot be read as "this host is a Raspberry Pi".
grep -Eq -- 'when:.*grep_cpuinfo_raspberrypi\.rc.*== 0.*grep_device_tree_model_raspberrypi\.rc.*== 0' \
"$tasks_file" || {
printf 'raspberry_pi gate no longer tests both probe return codes for 0\n' >&2
exit 1
}
grep -Eq -- 'grep_cpuinfo_raspberrypi\.rc \| default\(1\)' "$tasks_file" || {
printf 'raspberry_pi gate does not default the /proc/cpuinfo probe rc\n' >&2
exit 1
}
grep -Eq -- 'grep_device_tree_model_raspberrypi\.rc \| default\(1\)' "$tasks_file" || {
printf 'raspberry_pi gate does not default the /proc/device-tree probe rc\n' >&2
exit 1
}
# The distro specific include must remain gated on the detection result so a
# non Raspberry Pi host skips setup/ and teardown/ entirely.
grep -Eq -- 'raspberry_pi\|default\(false\)' "$tasks_file" || {
printf 'OS specific tasks are no longer gated on raspberry_pi\n' >&2
exit 1
}
printf 'Raspberry Pi detection regression test passed\n'
+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'
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
"""Regression test for the verify_from_outside nginx HTTP assert.
The "Assert that the nginx welcome page is available" task in
molecule/resources/verify_from_outside/tasks/test/deploy-example.yml performs
a plain HTTP GET (ansible.builtin.uri) against the MetalLB VIP that the load
balancer pool assigned, reaching it from the CI runner over the VirtualBox
host-only network.
The MetalLB speaker announces the VIP on the host-only network, and the runner
may briefly not see it in its ARP table yet. That manifests as a single-shot
"status -1 / No route to host" failure that fails the whole verify play even
though the cluster is healthy. Like the sibling load-balancer address wait and
the k3s_server_post metallb waits, the assert must retry until it observes
HTTP 200 with the welcome page.
A bare assert with no retry would otherwise fail an otherwise-green cluster on
a transient announcement miss.
"""
from __future__ import print_function
import os
import subprocess
import yaml
def repo_root():
return subprocess.check_output(
["git", "rev-parse", "--show-toplevel"], text=True
).strip()
def fail(message):
raise SystemExit("verify nginx assert test failed: " + message)
def find_task(play, name):
block = play.get("block")
if not isinstance(block, list):
fail("expected a block list of tasks")
for entry in block:
if entry.get("name") == name:
return entry
fail("could not find the '{0}' task".format(name))
return None
def check_retry_wiring(task, name):
if not task.get("register"):
fail("{0} does not register a result; without retry wiring a transient "
"MetalLB VIP announcement miss fails the verify play".format(name))
until = task.get("until")
if not until:
fail("{0} does not retry; a transient MetalLB VIP announcement miss "
"on the host-only network would fail the verify play".format(name))
if isinstance(until, str):
until = [until]
joined = " | ".join(str(expr) for expr in until)
if "result.status == 200" not in joined:
fail("{0} does not retry until the HTTP status is 200".format(name))
if "Welcome to nginx!" not in joined:
fail("{0} does not verify the welcome page content".format(name))
if task.get("retries") is None:
fail("{0} is missing retries".format(name))
if task.get("delay") is None:
fail("{0} is missing delay".format(name))
def main():
task_file = os.path.join(
repo_root(),
"molecule", "resources", "verify_from_outside", "tasks", "test",
"deploy-example.yml",
)
with open(task_file, encoding="utf-8") as handle:
plays = yaml.safe_load(handle)
play = plays[0]
task = find_task(play, "Assert that the nginx welcome page is available")
if not task.get("ansible.builtin.uri"):
fail("task does not use ansible.builtin.uri to reach the VIP")
check_retry_wiring(task, "Assert that the nginx welcome page is available")
print("verify nginx assert regression test passed")
if __name__ == "__main__":
main()
+4
View File
@@ -14,6 +14,7 @@ on:
- '**/FUNDING.yml'
- '**/host.ini'
- '**/*.md'
- '.github/ISSUE_TEMPLATE/**'
- '**/.editorconfig'
- '**/ansible.example.cfg'
- '**/deploy.sh'
@@ -31,10 +32,13 @@ concurrency:
jobs:
pre:
if: github.actor != 'dependabot[bot]'
uses: ./.github/workflows/cache.yml
needs: [lint]
lint:
if: github.actor != 'dependabot[bot]'
uses: ./.github/workflows/lint.yml
test:
if: github.actor != 'dependabot[bot]'
uses: ./.github/workflows/test.yml
needs: [pre, lint]
+1 -1
View File
@@ -68,7 +68,7 @@ jobs:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 7.0.1
- name: Ensure SHA pinned actions
uses: zgosalvez/github-actions-ensure-sha-pinned-actions@3db98c0363e2fa5df3e1c4c471777a7c10b24cc9 # 5.0.5
uses: zgosalvez/github-actions-ensure-sha-pinned-actions@c4e71056006d29f90204b2d063be337109507d62 # 5.0.10
with:
allowlist: |
aws-actions/
+2 -2
View File
@@ -88,7 +88,7 @@ jobs:
trap stop_monitor EXIT
/usr/bin/time -v -o "$timing_file" \
molecule test --scenario-name ${{ matrix.scenario }}
timeout-minutes: 90
timeout-minutes: 180
env:
ANSIBLE_K3S_LOG_DIR: ${{ runner.temp }}/logs/k3s-ansible/${{ matrix.scenario }}
ANSIBLE_SSH_RETRIES: 4
@@ -109,7 +109,7 @@ jobs:
- name: Upload log files
if: always() # do this even if a step before has failed
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # 7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # 7.0.2
with:
name: logs-${{ matrix.scenario }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
+111 -4
View File
@@ -23,10 +23,6 @@ repos:
additional_dependencies: [ansible-core==2.18.0]
language_version: python3.12
args: [--offline]
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: v0.9.0.6
hooks:
- id: shellcheck
- repo: https://github.com/Lucas-C/pre-commit-hooks
rev: v1.5.4
hooks:
@@ -38,6 +34,12 @@ repos:
- id: fix-smartquotes
- repo: local
hooks:
- id: shellcheck
name: shellcheck
entry: shellcheck
language: python
additional_dependencies: [shellcheck-py==0.11.0.1]
types: [shell]
- id: cleanup-runner-resources-test
name: cleanup runner resources test
entry: .github/scripts/test-cleanup-runner-resources.sh
@@ -62,3 +64,108 @@ repos:
language: system
pass_filenames: false
files: ^roles/k3s_server/tasks/(main|join_master)\.yml$|^\.github/scripts/test-k3s-server-bootstrap\.sh$
- id: k3s-init-log-destination-test
name: K3s init log destination test
entry: .github/scripts/test-k3s-init-log-destination.py
language: system
pass_filenames: false
files: ^roles/k3s_server/tasks/main\.yml$|^\.github/scripts/test-k3s-init-log-destination\.py$
- 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: disable-swap-test
name: Disable swap test
entry: .github/scripts/test-disable-swap.sh
language: system
pass_filenames: false
files: ^roles/prereq/(tasks/main|defaults/main)\.yml$|^\.github/scripts/test-disable-swap\.sh$
- id: raspberrypi-detection-test
name: Raspberry Pi detection test
entry: .github/scripts/test-raspberrypi-detection.sh
language: system
pass_filenames: false
files: ^roles/raspberrypi/(tasks/main|defaults/main)\.yml$|^\.github/scripts/test-raspberrypi-detection\.sh$
- id: cilium-bgp-manifest-test
name: Cilium BGP manifest test
entry: python3 .github/scripts/test-cilium-bgp-manifest.py
language: python
additional_dependencies:
- Jinja2>=3.1
pass_filenames: false
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
name: kube-vip manifest test
entry: python3 .github/scripts/test-kube-vip-manifest.py
language: python
additional_dependencies:
- Jinja2>=3.1
pass_filenames: false
files: ^roles/k3s_server/templates/vip\.yaml\.j2$|^\.github/scripts/test-kube-vip-manifest\.py$
- id: metallb-remote-read-test
name: MetalLB remote read test
entry: .github/scripts/test-metallb-remote-read.sh
language: system
pass_filenames: false
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-namespace-test
name: MetalLB namespace test
entry: python3 .github/scripts/test-metallb-namespace.py
language: python
additional_dependencies:
- PyYAML
pass_filenames: false
files: ^roles/k3s_server_post/tasks/metallb\.yml$|^\.github/scripts/test-metallb-namespace\.py$
- id: metallb-deploy-condition-test
name: MetalLB deploy condition test
entry: python3 .github/scripts/test-metallb-deploy-condition.py
language: python
additional_dependencies:
- Jinja2>=3.1
- PyYAML
pass_filenames: false
files: ^roles/k3s_server/tasks/main\.yml$|^roles/k3s_server_post/tasks/main\.yml$|^\.github/scripts/test-metallb-deploy-condition\.py$ # noqa yaml[line-length]
- id: kube-vip-deploy-condition-test
name: kube-vip deploy condition test
entry: python3 .github/scripts/test-kube-vip-deploy-condition.py
language: python
additional_dependencies:
- Jinja2>=3.1
- PyYAML
pass_filenames: false
files: ^roles/k3s_server/tasks/main\.yml$|^\.github/scripts/test-kube-vip-deploy-condition\.py$ # noqa yaml[line-length]
- id: default-interface-test
name: default interface test
entry: python3 .github/scripts/test-default-interface.py
language: python
additional_dependencies:
- Jinja2>=3.1
pass_filenames: false
files: ^inventory/sample/group_vars/all\.yml$|^\.github/scripts/test-default-interface\.py$
- id: verify-nginx-assert-test
name: verify nginx assert test
entry: python3 .github/scripts/test-verify-nginx-assert.py
language: python
additional_dependencies:
- PyYAML
pass_filenames: false
files: ^molecule/resources/verify_from_outside/tasks/test/deploy-example\.yml$|^\.github/scripts/test-verify-nginx-assert\.py$ # noqa yaml[line-length]
+130
View File
@@ -0,0 +1,130 @@
# Agent Guide
This file is the canonical repository guide for coding agents and automated contributors. Read it before making
changes. Human contributors should also review [CONTRIBUTING.md](CONTRIBUTING.md).
## Project overview
This repository is an Ansible collection that provisions and resets highly available k3s clusters. It supports
multiple networking choices, including Flannel, Calico, Cilium, kube-vip, and MetalLB.
The main entry points are:
- `site.yml`: provision or update a cluster.
- `reset.yml`: remove k3s from a cluster.
- `reboot.yml`: reboot cluster nodes.
- `inventory/sample/`: example inventory and variables.
- `roles/`: reusable Ansible roles used by the playbooks.
- `molecule/`: integration scenarios run by CI.
- `.github/scripts/`: CI support scripts and focused regression tests.
## Source of truth
- Role defaults belong in `roles/<role>/defaults/main.yml`.
- Tasks belong in `roles/<role>/tasks/` and handlers in `roles/<role>/handlers/`.
- Example user configuration belongs in `inventory/sample/`.
- User-facing setup and variable documentation belongs in `README.md`.
- Contributor workflows and review expectations belong in `CONTRIBUTING.md`.
- Agent-specific repository instructions belong in this file.
Keep `CLAUDE.md` and `.github/copilot-instructions.md` as small pointers to this file. Do not duplicate these
instructions in tool-specific files.
## Development setup
Use a Python virtual environment. Do not commit the environment, generated logs, inventories, kubeconfigs, or
credentials.
```bash
python3 -m venv .env
source .env/bin/activate
python3 -m pip install -r requirements.txt
ansible-galaxy collection install -r collections/requirements.yml
pre-commit install
```
`ansible.cfg` is intentionally ignored. Copy `ansible.example.cfg` when local configuration is needed.
## Working rules
1. Inspect the current branch and worktree before editing. Preserve unrelated user changes.
2. Keep changes focused. Avoid drive-by formatting or dependency updates.
3. Never add real IP addresses, hostnames, tokens, private keys, kubeconfigs, or inventory secrets.
4. Use placeholders in examples and redact sensitive values from logs and issue reports.
5. Preserve idempotence. An already-converged host should not report changes without a real state transition.
6. Prefer Ansible modules over `ansible.builtin.command` or `ansible.builtin.shell`. When a command is required,
define accurate `changed_when` and `failed_when` behavior.
7. Use fully qualified collection names, such as `ansible.builtin.copy`.
8. Put configurable values in role defaults or inventory variables. Avoid embedding environment-specific values in
tasks and templates.
9. Maintain compatibility with the operating systems and architectures listed in `README.md`.
10. Do not weaken lint rules, tests, or CI checks to make a change pass.
## Change guidance
### Ansible tasks and roles
- Use descriptive task names in sentence case.
- Use YAML booleans (`true` and `false`) rather than aliases.
- Quote file modes, for example `mode: "0644"`.
- Notify handlers only when the managed resource changes.
- Use `become: true` only where privilege escalation is needed.
- Update role defaults, sample inventory, and the README together when adding or renaming user-facing variables.
- Check reset behavior when provisioning introduces persistent services, files, mounts, or network state.
### Templates and manifests
- Keep Jinja logic small and readable. Move complicated decisions into task variables where practical.
- Render valid YAML after Jinja evaluation.
- Preserve explicit handling for optional and undefined variables.
- Add or update a focused test under `.github/scripts/` when changing generated Kubernetes manifests or bootstrap
behavior.
### Molecule scenarios
- Reuse `molecule/resources/` for shared behavior.
- Put scenario-specific inputs in `molecule/<scenario>/overrides.yml` and `verify-vars.yml`.
- Update `molecule/README.md` when adding, removing, or materially changing a scenario.
- Clean up resources created by tests, including failure paths.
## Validation
Run the smallest relevant checks while iterating, then run the complete local validation before considering a change
ready:
```bash
pre-commit run --all-files
```
For playbook or role changes, also run syntax checks with a non-sensitive inventory:
```bash
ansible-playbook site.yml --syntax-check -i inventory/sample/hosts.ini
ansible-playbook reset.yml --syntax-check -i inventory/sample/hosts.ini
```
Run focused regression scripts when their related files change. The mapping is defined in
`.pre-commit-config.yaml`.
Molecule tests require Vagrant, VirtualBox, host-only networking, and substantial local resources. Run the most
relevant scenario when that environment is available:
```bash
molecule test --scenario-name <scenario>
```
If a required test can't be run locally, state exactly which check was skipped and why. Never claim a check passed
unless it was executed.
## Documentation and review
- Keep commands copyable and examples free of secrets.
- Update documentation in the same change as user-visible behavior.
- Explain behavior changes, compatibility concerns, operational risks, and rollback steps in the pull request.
- Use conventional commit messages with a scope, for example `fix(k3s-server): handle an existing token safely`.
- Do not commit, push, open a pull request, or modify remote resources unless the user explicitly requests it.
## Definition of done
A change is ready for review when it is focused, documented, linted, tested in proportion to its risk, and shown in a
clean diff with no secrets or generated artifacts.
+3
View File
@@ -0,0 +1,3 @@
@AGENTS.md
`AGENTS.md` is the canonical repository guide. Follow it for all work in this repository.
+94
View File
@@ -0,0 +1,94 @@
# Contributing to k3s-ansible
Thank you for improving k3s-ansible. Contributions should be focused, safe to apply to existing clusters, and tested
in proportion to their operational impact.
## Before opening an issue
- Search existing issues and discussions for the same behavior.
- Review the [troubleshooting discussion](https://github.com/timothystewart6/k3s-ansible/discussions/20).
- Remove tokens, credentials, public IP addresses, private hostnames, and other sensitive values from logs and
configuration.
- For support requests, include the k3s-ansible revision, Ansible version, target operating system, architecture,
network provider, relevant sanitized variables, and a minimal reproduction.
Use the bug report template for reproducible defects and the feature request template for proposed behavior.
## Development environment
Fork and clone the repository, then create a branch from the latest `master`:
```bash
git switch master
git pull --ff-only
git switch -c <type>/<short-description>
```
Create a Python environment and install the pinned development dependencies:
```bash
python3 -m venv .env
source .env/bin/activate
python3 -m pip install -r requirements.txt
ansible-galaxy collection install -r collections/requirements.yml
pre-commit install
```
Copy `ansible.example.cfg` to the ignored `ansible.cfg` file if local Ansible configuration is needed. Start custom
inventories from `inventory/sample/`, keep them out of Git, and never use production credentials in tests.
## Making changes
- Keep each pull request focused on one problem or feature.
- Follow [AGENTS.md](AGENTS.md) for repository structure, implementation conventions, and safety requirements.
- Preserve idempotence and existing-cluster compatibility.
- Add or update tests for behavior changes and regressions.
- Update role defaults, sample inventory, and documentation when user-facing variables change.
- Consider both provisioning and reset behavior for persistent resources.
- Avoid unrelated reformatting and generated files.
## Validation
Run all pre-commit checks before submitting a pull request:
```bash
pre-commit run --all-files
```
For playbook or role changes, run syntax checks:
```bash
ansible-playbook site.yml --syntax-check -i inventory/sample/hosts.ini
ansible-playbook reset.yml --syntax-check -i inventory/sample/hosts.ini
```
Run the most relevant Molecule scenario when Vagrant, VirtualBox, and the required host networking are available:
```bash
molecule test --scenario-name <scenario>
```
See [molecule/README.md](molecule/README.md) for scenario details and local requirements. Pull requests should list
every check that was run and clearly identify checks that could not be run locally.
## Commits and pull requests
Use a conventional commit subject with a scope:
```text
type(scope): short description
```
Common types are `feat`, `fix`, `refactor`, `test`, `docs`, and `chore`. Write subjects in the imperative mood and
keep commits logically focused.
Pull requests should:
- Describe the problem and the resulting behavior.
- Identify compatibility, security, networking, and upgrade risks.
- Include testing evidence without sensitive data.
- Call out documentation and sample configuration changes.
- Link related issues with `Fixes #<issue>` when applicable.
- Avoid checking boxes for tests that were not run.
Maintainers may ask for a change to be split when unrelated work makes it difficult to review or roll back.
+113 -31
View File
@@ -2,51 +2,70 @@
![Fully Automated K3S etcd High Availability Install](https://img.youtube.com/vi/CbkEWcUZ7zM/0.jpg)
This playbook will build an HA Kubernetes cluster with `k3s`, `kube-vip` and MetalLB via `ansible`.
This Ansible collection builds a highly available Kubernetes cluster with k3s. It supports kube-vip for the control
plane virtual IP, multiple CNI options, and either MetalLB or kube-vip for service load balancing.
This is based on the work from [this fork](https://github.com/212850a/k3s-ansible) which is based on the work from [k3s-io/k3s-ansible](https://github.com/k3s-io/k3s-ansible). It uses [kube-vip](https://kube-vip.io/) to create a load balancer for control plane, and [metal-lb](https://metallb.universe.tf/installation/) for its service `LoadBalancer`.
If you want more context on how this works, see:
For more context on how it works, see:
📄 [Documentation](https://technotim.com/posts/k3s-etcd-ansible/) (including example commands)
📺 [Watch the Video](https://www.youtube.com/watch?v=CbkEWcUZ7zM)
## Project guides
- [Getting started](#-getting-started)
- [Configuration variables](#variables)
- [Upgrading an existing cluster](#-upgrading-an-existing-cluster)
- [Local Molecule testing](molecule/README.md)
- [Contributing guidelines](CONTRIBUTING.md)
- [Repository guide for coding agents](AGENTS.md)
## 📖 k3s Ansible Playbook
Build a Kubernetes cluster using Ansible with k3s. The goal is easily install a HA Kubernetes cluster on machines running:
Build a Kubernetes cluster using Ansible and k3s. The goal is to make a highly available cluster straightforward to
install on machines running:
- [x] Debian (tested on version 13)
- [x] Ubuntu (tested on version 26.04 LTS)
- [x] Rocky (tested on version 10)
on processor architecture:
Supported processor architectures are:
- [X] x64
- [X] arm64
- [X] armhf
- [x] x64
- [x] arm64
- [x] armhf
## ✅ System requirements
- Control Node (the machine you are running `ansible` commands) must have Ansible 2.11+ If you need a quick primer on Ansible [you can check out my docs and setting up Ansible](https://technotim.com/posts/ansible-automation/).
- The control node, which runs the Ansible commands, must have Ansible 2.11 or newer. For a quick primer, see
[setting up Ansible](https://technotim.com/posts/ansible-automation/).
- You will also need to install collections that this playbook uses by running `ansible-galaxy collection install -r ./collections/requirements.yml` (important❗)
- Install the required collections with
`ansible-galaxy collection install -r ./collections/requirements.yml`.
- [`netaddr` package](https://pypi.org/project/netaddr/) must be available to Ansible. If you have installed Ansible via apt, this is already taken care of. If you have installed Ansible via `pip`, make sure to install `netaddr` into the respective virtual environment.
- `server` and `agent` nodes should have passwordless SSH access, if not you can supply arguments to provide credentials `--ask-pass --ask-become-pass` to each command.
- Server and agent nodes should support passwordless SSH access. Otherwise, pass `--ask-pass --ask-become-pass` to
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
### 🍴 Preparation
First create a new directory based on the `sample` directory within the `inventory` directory:
Create a cluster-specific inventory from the sample. The `inventory/` directory ignores custom inventory content so
credentials and environment details aren't committed accidentally.
```bash
cp -R inventory/sample inventory/my-cluster
```
Second, edit `inventory/my-cluster/hosts.ini` to match the system information gathered above
Edit `inventory/my-cluster/hosts.ini` to match the target hosts.
For example:
@@ -67,9 +86,10 @@ node
If multiple hosts are in the master group, the playbook will automatically set up k3s in [HA mode with etcd](https://rancher.com/docs/k3s/latest/en/installation/ha-embedded/).
Finally, copy `ansible.example.cfg` to `ansible.cfg` and adapt the inventory path to match the files that you just created.
Copy `ansible.example.cfg` to `ansible.cfg`, then update its inventory path. The local `ansible.cfg` file is ignored by
Git.
This requires at least k3s version `1.19.1` however the version is configurable by using the `k3s_version` variable.
The minimum k3s version is `1.19.1`. Select the desired version with the `k3s_version` variable.
If needed, you can also edit `inventory/my-cluster/group_vars/all.yml` to match your environment.
@@ -81,7 +101,8 @@ Start provisioning of the cluster using the following command:
ansible-playbook site.yml -i inventory/my-cluster/hosts.ini
```
After deployment control plane will be accessible via virtual ip-address which is defined in inventory/group_vars/all.yml as `apiserver_endpoint`
After deployment, the control plane is accessible through the virtual IP defined by `apiserver_endpoint` in the
inventory variables.
### 🔥 Remove k3s cluster
@@ -89,7 +110,51 @@ After deployment control plane will be accessible via virtual ip-address which i
ansible-playbook reset.yml -i inventory/my-cluster/hosts.ini
```
>You should also reboot these nodes due to the VIP not being destroyed
> Reboot the nodes after reset because the virtual IP may remain configured.
### ⏻️ Reboot Cluster Nodes
Reboot all cluster nodes at once or stage the reboot across the cluster.
```bash
ansible-playbook reboot.yml -i inventory/my-cluster/hosts.ini
```
To reboot the nodes in batches, set `concurrent_reboots` to the number of nodes
to reboot at a time (or a percentage). Optionally set `wait_seconds_after_reboot`
to pause after each batch so pods in the freshly rebooted batch can settle
before the next batch reboots.
```bash
ansible-playbook reboot.yml -i inventory/my-cluster/hosts.ini \
--extra-vars 'concurrent_reboots=2 wait_seconds_after_reboot=30'
```
## 🔁 Upgrading an existing cluster
These version variables select the components used for a **fresh** installation.
They are not a supported direct in-place upgrade path for an existing cluster.
K3s, Calico, and Cilium each require staged upgrades for long-lived clusters.
- **K3s**: do not jump an embedded-etcd cluster straight to Kubernetes 1.36.
Upgrade one Kubernetes minor version at a time. From the sample default
(`v1.30.2+k3s2`) the sequence is: the latest supported 1.30 patch, then 1.31,
1.32, a 1.33 patch that contains etcd 3.5.26 (for example `v1.33.7+k3s3`),
then 1.34, 1.35, and finally 1.36. Upgrade servers one at a time before
agents. Take backups and confirm cluster health at each step; this playbook
does not automate the upgrade, so those remain manual operational steps. See
[K3s manual upgrades](https://docs.k3s.io/upgrades/manual) and the
[v1.34 release notes](https://docs.k3s.io/release-notes/v1.34.X).
- **Cilium**: upstream supports only consecutive minor upgrades. Update to the
latest patch of the current minor, then upgrade 1.17, 1.18, 1.19, and 1.20 in
order, reading each version's upgrade notes and running preflight checks.
Do not attempt a direct upgrade from an old Cilium to 1.20.
- **Calico**: starting with 3.28 the v3 resource UID behavior changed. If you
have operators with OwnerReferences pointing to `projectcalico.org/v3`
resources, remove and recreate those references around an in-place upgrade.
- **MetalLB**: this project installs application tag `v0.16.0`. A newer
chart-only tag such as `metallb-chart-0.16.1` is not an application or image
release and must not be used as the controller or speaker image tag.
## ⚙️ Kube Config
@@ -98,13 +163,21 @@ To copy your `kube config` locally so that you can access your **Kubernetes** cl
```bash
scp debian@master_ip:/etc/rancher/k3s/k3s.yaml ~/.kube/config
```
If you get file Permission denied, go into the node and temporarly run:
If the copy fails with a permission error, grant the SSH user temporary read access using the least permissive method
available for the target system. Restore the original ownership and permissions immediately after copying. Avoid
world-writable permissions on the kubeconfig because it contains cluster credentials.
For example, copy the file to a temporary user-readable path from the control node:
```bash
sudo chmod 777 /etc/rancher/k3s/k3s.yaml
ssh debian@master_ip 'sudo install -o "$(id -un)" -m 0600 /etc/rancher/k3s/k3s.yaml /tmp/k3s.yaml'
```
Then copy with the scp command and reset the permissions back to:
Copy `/tmp/k3s.yaml`, then remove the temporary remote copy:
```bash
sudo chmod 600 /etc/rancher/k3s/k3s.yaml
scp debian@master_ip:/tmp/k3s.yaml ~/.kube/config
ssh debian@master_ip rm -f /tmp/k3s.yaml
```
You'll then want to modify the config to point to master IP by running:
@@ -124,7 +197,7 @@ See the commands [here](https://technotim.com/posts/k3s-etcd-ansible/#testing-yo
| `download` | `k3s_version` | string | ❌ | Required | K3s binaries version |
| `k3s_agent`, `k3s_server`, `k3s_server_post` | `apiserver_endpoint` | string | ❌ | Required | Virtual ip-address configured on each master |
| `k3s_agent` | `extra_agent_args` | string | `null` | Not required | Extra arguments for agents nodes |
| `k3s_agent`, `k3s_server` | `group_name_master` | string | `null` | Not required | Name othe master group |
| `k3s_agent`, `k3s_server` | `group_name_master` | string | `null` | Not required | Name of the master group |
| `k3s_agent` | `k3s_token` | string | `null` | Not required | Token used to communicate between masters |
| `k3s_agent`, `k3s_server` | `proxy_env` | dict | `null` | Not required | Internet proxy configurations |
| `k3s_agent`, `k3s_server` | `proxy_env.HTTP_PROXY` | string | ❌ | Required | HTTP internet proxy |
@@ -146,11 +219,14 @@ See the commands [here](https://technotim.com/posts/k3s-etcd-ansible/#testing-yo
| `k3s_server` | `kube_vip_bgp_peers` | list | `[]` | Not required | List of BGP peer ASN & address pairs |
| `k3s_server` | `kube_vip_bgp_peers_groups` | list | `['k3s_master']` | Not required | Inventory group in which to search for additional `kube_vip_bgp_peers` parameters to merge. |
| `k3s_server` | `kube_vip_iface` | string | `~` | Not required | Explicitly define an interface that ALL control nodes should use to propagate the VIP, define it here. Otherwise, kube-vip will determine the right interface automatically at runtime. |
| `k3s_server` | `kube_vip_tag_version` | string | `v0.7.2` | Not required | Image tag for kube-vip |
| `k3s_server` | `kube_vip_cloud_provider_tag_version` | string | `main` | Not required | Tag for kube-vip-cloud-provider manifest when enable |
| `k3s_server` | `kube_vip_endpoint` | string | `~` | Not required | Overrides the internal address kube-vip binds/listens on, which can differ from the announced apiserver_endpoint for complex routing/tunnels. Defaults to apiserver_endpoint. |
| `k3s_server` | `kube_vip_tag_version` | string | `v1.2.3` | Not required | Image tag for kube-vip |
| `k3s_server` | `kube_vip_cloud_provider_tag_version` | string | `v0.0.12` | Not required | Tag for kube-vip-cloud-provider manifest when enable |
| `k3s_server` | `kube_vip_enabled` | bool | `true` | Not required | Enable kube-vip install, covering both the control-plane VIP and the service load balancer. Set false to skip kube-vip entirely (single node or external LB). |
| `k3s_server`, `k3_server_post` | `kube_vip_lb_ip_range` | string | `~` | Not required | IP range for kube-vip load balancer |
| `k3s_server`, `k3s_server_post` | `metal_lb_controller_tag_version` | string | `v0.14.3` | Not required | Image tag for MetalLB |
| `k3s_server` | `metal_lb_speaker_tag_version` | string | `v0.14.3` | Not required | Image tag for MetalLB |
| `k3s_server`, `k3s_server_post` | `metal_lb_enabled` | bool | `true` | Not required | Enable MetalLB install for service load balancing. Set false to skip MetalLB (external LB). |
| `k3s_server`, `k3s_server_post` | `metal_lb_controller_tag_version` | string | `v0.16.0` | Not required | Image tag for MetalLB |
| `k3s_server` | `metal_lb_speaker_tag_version` | string | `v0.16.0` | Not required | Image tag for MetalLB |
| `k3s_server` | `metal_lb_type` | string | `native` | Not required | Use FRR mode or native. Valid values are `frr` and `native` |
| `k3s_server` | `retry_count` | int | `20` | Not required | Amount of retries when verifying that nodes joined |
| `k3s_server` | `server_init_args` | string | ❌ | Not required | Arguments for server nodes |
@@ -162,7 +238,7 @@ See the commands [here](https://technotim.com/posts/k3s-etcd-ansible/#testing-yo
| `k3s_server_post` | `calico_natOutgoing` | string | `Enabled` | Not required | IP pool NAT outgoing |
| `k3s_server_post` | `calico_nodeSelector` | string | `all()` | Not required | IP pool node selector |
| `k3s_server_post` | `calico_iface` | string | `~` | Not required | The network interface used for when Calico is enabled |
| `k3s_server_post` | `calico_tag` | string | `v3.27.2` | Not required | Calico version tag |
| `k3s_server_post` | `calico_tag` | string | `v3.32.1` | Not required | Calico version tag |
| `k3s_server_post` | `cilium_bgp_my_asn` | int | `64513` | Not required | Local ASN for BGP peer |
| `k3s_server_post` | `cilium_bgp_peer_asn` | int | `64512` | Not required | BGP peer ASN |
| `k3s_server_post` | `cilium_bgp_peer_address` | string | `~` | Not required | BGP peer address |
@@ -171,20 +247,24 @@ See the commands [here](https://technotim.com/posts/k3s-etcd-ansible/#testing-yo
| `k3s_server_post` | `cilium_bgp_lb_cidr` | string | `192.168.31.0/24` | Not required | BGP load balancer IP range |
| `k3s_server_post` | `cilium_exportPodCIDR` | bool | `true` | Not required | Export pod CIDR |
| `k3s_server_post` | `cilium_hubble` | bool | `true` | Not required | Enable Cilium Hubble |
| `k3s_server_post` | `cilium_hubble` | bool | `true` | Not required | Enable Cilium Hubble |
| `k3s_server_post` | `cilium_mode` | string | `native` | Not required | Inner-node communication mode (choices are `native` and `routed`) |
| `k3s_server_post` | `cilium_mode` | string | `native` | Not required | Inner-node communication mode (choices are `native` and `tunnel`; `routed` is a deprecated alias for `tunnel`) |
| `k3s_server_post` | `cilium_tag` | string | `v1.20.0` | Not required | Cilium version tag |
| `k3s_server_post` | `cilium_cli_tag` | string | `v0.19.7` | Not required | Cilium CLI version tag |
| `k3s_server_post` | `cluster_cidr` | string | `10.52.0.0/16` | Not required | Inner-cluster IP range |
| `k3s_server_post` | `enable_bpf_masquerade` | bool | `true` | Not required | Use IP masquerading |
| `k3s_server_post` | `kube_proxy_replacement` | bool | `true` | Not required | Replace the native kube-proxy with Cilium |
| `k3s_server_post` | `metal_lb_available_timeout` | string | `240s` | Not required | Wait for MetalLB resources |
| `k3s_server_post` | `metal_lb_ip_range` | string | `192.168.30.80-192.168.30.90` | Not required | MetalLB ip range for load balancer |
| `k3s_server_post` | `metal_lb_controller_tag_version` | string | `v0.14.3` | Not required | Image tag for MetalLB |
| `k3s_server_post` | `metal_lb_controller_tag_version` | string | `v0.16.0` | Not required | Image tag for MetalLB |
| `k3s_server_post` | `metal_lb_mode` | string | `layer2` | Not required | Metallb mode (choices are `bgp` and `layer2`) |
| `k3s_server_post` | `metal_lb_bgp_my_asn` | string | `~` | Not required | BGP ASN configurations |
| `k3s_server_post` | `metal_lb_bgp_peer_asn` | string | `~` | Not required | BGP peer ASN configurations |
| `k3s_server_post` | `metal_lb_bgp_peer_address` | string | `~` | Not required | BGP peer address |
| `lxc` | `custom_reboot_command` | string | `~` | Not required | Command to run on reboot |
| `reboot` (playbook) | `concurrent_reboots` | int/string | `100%` | Not required | Number (or percentage) of nodes to reboot at a time for a staggered reboot |
| `reboot` (playbook) | `wait_seconds_after_reboot` | int | `0` | Not required | Pause in seconds between staggered reboot batches |
| `prereq` | `system_timezone` | string | `null` | Not required | Timezone to be set on all nodes |
| `prereq` | `disable_swap` | bool | `true` | Not required | Disable swap on all cluster nodes (swapoff + comment out /etc/fstab swap entries), all-or-nothing |
| `proxmox_lxc`, `reset_proxmox_lxc` | `proxmox_lxc_ct_ids` | list | ❌ | Required | Proxmox container ID list |
| `raspberrypi` | `state` | string | `present` | Not required | Indicates whether the k3s prerequisites for Raspberry Pi should be set up (possible values are `present` and `absent`) |
@@ -200,9 +280,11 @@ It is run automatically in CI, but you can also run the tests locally.
This might be helpful for quick feedback in a few cases.
You can find more information about it [here](molecule/README.md).
### Pre-commit Hooks
### Pre-commit hooks
This repo uses `pre-commit` and `pre-commit-hooks` to lint and fix common style and syntax errors. Be sure to install python packages and then run `pre-commit install`. For more information, see [pre-commit](https://pre-commit.com/)
This repository uses `pre-commit` to check style, syntax, Ansible content, and shell scripts. Install the Python
dependencies, run `pre-commit install` once, and run `pre-commit run --all-files` before submitting a change. See
[CONTRIBUTING.md](CONTRIBUTING.md) for the complete development workflow.
## 🌌 Ansible Galaxy
+44 -12
View File
@@ -1,5 +1,5 @@
---
k3s_version: v1.30.2+k3s2
k3s_version: v1.36.4+k3s1
# this is the user that has ssh access to these machines
ansible_user: ansibleuser
systemd_dir: /etc/systemd/system
@@ -7,20 +7,31 @@ systemd_dir: /etc/systemd/system
# Set your timezone
system_timezone: Your/Timezone
# k3s recommends swap be disabled on every cluster node. Applied uniformly to all
# nodes (all-or-nothing) in the prereq role. Set to false to leave swap enabled.
disable_swap: true
# interface which will be used for flannel
flannel_iface: eth0
# Defaults to each host's default IPv4 interface (e.g. eth0, enp1s0, ens3)
# so KVM/cloud hosts without eth0 work out of the box. Override per-host if needed.
flannel_iface: "{{ ansible_facts.default_ipv4.interface }}"
# uncomment calico_iface to use tigera operator/calico cni instead of flannel https://docs.tigera.io/calico/latest/about
# calico_iface: "eth0"
# calico_iface: "{{ ansible_facts.default_ipv4.interface }}"
calico_ebpf: false # use eBPF dataplane instead of iptables
calico_tag: v3.28.0 # calico version tag
calico_tag: v3.32.1 # calico version tag
# uncomment cilium_iface to use cilium cni instead of flannel or calico
# ensure v4.19.57, v5.1.16, v5.2.0 or more recent kernel
# cilium_iface: "eth0"
cilium_mode: native # native when nodes on same subnet or using bgp, else set routed
cilium_tag: v1.16.0 # cilium version tag
# cilium_iface: "{{ ansible_facts.default_ipv4.interface }}"
cilium_mode: native # native when nodes are on the same subnet or use BGP, otherwise set tunnel
cilium_tag: v1.20.0 # cilium version tag
cilium_cli_tag: v0.19.7 # cilium cli version tag
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
cluster_cidr: 10.52.0.0/16
@@ -37,6 +48,11 @@ cilium_bgp_lb_cidr: 192.168.31.0/24 # cidr for cilium loadbalancer ipam
# enable kube-vip ARP broadcasts
kube_vip_arp: true
# (optional) overrides the address kube-vip binds/listens on internally, which
# can differ from the announced apiserver_endpoint for complex routing/tunnels.
# Defaults to apiserver_endpoint. Also used to derive the kube-vip subnet.
# kube_vip_endpoint: 10.66.1.5
# enable kube-vip BGP peering
kube_vip_bgp: false
@@ -46,7 +62,9 @@ kube_vip_bgp_as: "64513" # Defines the AS for the BGP server
kube_vip_bgp_peeraddress: "192.168.30.1" # Defines the address for the BGP peer
kube_vip_bgp_peeras: "64512" # Defines the AS for the BGP peer
# apiserver_endpoint is virtual ip-address which will be configured on each master
# apiserver_endpoint is virtual ip-address which will be configured on each master.
# This must be a free, routable IP on your network (not already assigned to a host
# or service), and is used by kube-vip / MetalLB to expose the Kubernetes API.
apiserver_endpoint: 192.168.30.222
# k3s_token is required masters can talk together securely
@@ -84,15 +102,23 @@ extra_agent_args: >-
{{ extra_args }}
# image tag for kube-vip
kube_vip_tag_version: v0.8.2
kube_vip_tag_version: v1.2.3
# enable kube-vip (covers both the control-plane VIP and service load balancing)
# set false for a single node or when an external load balancer is used
kube_vip_enabled: true
# tag for kube-vip-cloud-provider manifest
# kube_vip_cloud_provider_tag_version: "main"
# kube_vip_cloud_provider_tag_version: "v0.0.12"
# kube-vip ip range for load balancer
# (uncomment to use kube-vip for services instead of MetalLB)
# kube_vip_lb_ip_range: "192.168.30.80-192.168.30.90"
# enable MetalLB for service load balancing
# set false when using an external load balancer
metal_lb_enabled: true
# metallb type frr or native
metal_lb_type: native
@@ -105,12 +131,18 @@ metal_lb_mode: layer2
# metal_lb_bgp_peer_address: "192.168.30.1"
# image tag for metal lb
metal_lb_speaker_tag_version: v0.14.8
metal_lb_controller_tag_version: v0.14.8
metal_lb_speaker_tag_version: v0.16.0
metal_lb_controller_tag_version: v0.16.0
# metallb ip range for load balancer
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
# in your hosts.ini file.
# Please read https://gist.github.com/triangletodd/02f595cd4c0dc9aac5f7763ca2264185 before using this.
+6
View File
@@ -0,0 +1,6 @@
---
# Durable verify inputs for the calico (Calico CNI + MetalLB) scenario.
verify_cni: calico
verify_lb: metallb
verify_lb_ip_range:
- 192.168.30.100-192.168.30.109
+6
View File
@@ -0,0 +1,6 @@
---
# Durable verify inputs for the cilium (Cilium CNI + MetalLB) scenario.
verify_cni: cilium
verify_lb: metallb
verify_lb_ip_range:
- 192.168.30.110-192.168.30.119
+9
View File
@@ -0,0 +1,9 @@
---
# Durable verify inputs for the default (flannel + MetalLB) scenario.
# These are plain inventory vars linked into the shared Molecule inventory so
# the verify play can see them even though the converge play's set_fact values
# are not persisted between the two Ansible processes.
verify_cni: flannel
verify_lb: metallb
verify_lb_ip_range:
- 192.168.30.80-192.168.30.90
+7
View File
@@ -0,0 +1,7 @@
---
# Durable verify inputs for the ipv6 (flannel CNI + MetalLB) scenario.
verify_cni: flannel
verify_lb: metallb
verify_lb_ip_range:
- fdad:bad:ba55::1b:0/112
- 192.168.123.80-192.168.123.90
+9
View File
@@ -0,0 +1,9 @@
---
# Durable verify inputs for the kube-vip (flannel CNI + kube-vip LB) scenario.
verify_cni: flannel
verify_lb: kube-vip
# The kube-vip cloud provider tag is not defined in the linked sample group
# vars (its sample entry is commented out), so it is supplied here.
verify_kube_vip_cloud_provider_tag: v0.0.12
verify_lb_ip_range:
- 192.168.30.110-192.168.30.119
+3
View File
@@ -1,5 +1,8 @@
---
- name: Verify
hosts: all
vars_files:
- >-
{{ lookup("ansible.builtin.env", "MOLECULE_SCENARIO_DIRECTORY") }}/verify-vars.yml
roles:
- verify_from_outside
@@ -7,6 +7,8 @@
ansible.builtin.import_tasks: kubecfg-fetch.yml
- name: "TEST CASE: Get nodes"
ansible.builtin.include_tasks: test/get-nodes.yml
- name: "TEST CASE: Verify components"
ansible.builtin.include_tasks: test/verify-components.yml
- name: "TEST CASE: Deploy example"
ansible.builtin.include_tasks: test/deploy-example.yml
always:
@@ -32,17 +32,95 @@
metallb_port: spec.ports[0].port
register: nginx_services
- name: Wait for the load balancer address to be assigned
ansible.builtin.set_fact:
nginx_lb_ip: >-
{{
nginx_services.resources[0].status.loadBalancer.ingress[0].ip
if (nginx_services.resources | length > 0) and
(nginx_services.resources[0].status.loadBalancer.ingress is defined) and
(nginx_services.resources[0].status.loadBalancer.ingress | length > 0)
else ''
}}
- name: Retry until the load balancer service has an external IP
block:
- name: Refresh nginx service until it has an assigned address
kubernetes.core.k8s_info:
kind: service
name: nginx
namespace: "{{ testing_namespace }}"
kubeconfig: "{{ kubecfg_path }}"
register: nginx_lb_wait
until: >-
(nginx_lb_wait.resources | length > 0) and
(nginx_lb_wait.resources[0].status.loadBalancer.ingress is defined) and
(nginx_lb_wait.resources[0].status.loadBalancer.ingress | length > 0)
retries: 30
delay: 5
- name: Record the assigned load balancer address
ansible.builtin.set_fact:
nginx_lb_ip: >-
{{ nginx_lb_wait.resources[0].status.loadBalancer.ingress[0].ip }}
- name: Assert that the nginx welcome page is available
ansible.builtin.uri:
url: http://{{ ip | ansible.utils.ipwrap }}:{{ port_ }}/
url: http://{{ nginx_lb_ip | ansible.utils.ipwrap }}:{{ port_ }}/
return_content: true
register: result
failed_when: "'Welcome to nginx!' not in result.content"
# The MetalLB speaker announces the VIP on the host-only network, and the
# runner may briefly not see it in its ARP table yet. Retry so a transient
# announcement miss does not fail an otherwise healthy cluster, matching
# the load-balancer address wait above and the k3s_server_post metallb waits.
until:
- result.status is defined
- result.status == 200
- "'Welcome to nginx!' in result.content"
retries: 30
delay: 5
vars:
ip: >-
{{ nginx_services.resources[0].status.loadBalancer.ingress[0].ip }}
port_: >-
{{ nginx_services.resources[0].spec.ports[0].port }}
- name: Initialize load balancer address range check
ansible.builtin.set_fact:
lb_addr_in_range: false
lb_ip_value: "{{ nginx_lb_ip }}"
- name: Check load balancer address against start-end pools
ansible.builtin.set_fact:
lb_addr_in_range: true
loop: "{{ verify_lb_ip_range }}"
loop_control:
label: "{{ item }}"
when:
- "'-' in item"
- "'/' not in item"
- >-
(lb_ip_value | ansible.utils.ipaddr('int') | int) >=
(item.split('-')[0] | ansible.utils.ipaddr('int') | int)
- >-
(lb_ip_value | ansible.utils.ipaddr('int') | int) <=
(item.split('-')[1] | ansible.utils.ipaddr('int') | int)
- name: Check load balancer address against CIDR pools
ansible.builtin.set_fact:
lb_addr_in_range: true
loop: "{{ verify_lb_ip_range }}"
loop_control:
label: "{{ item }}"
when:
- "'/' in item"
- (lb_ip_value | ansible.utils.ipaddr(item)) is string
- name: Assert that the load balancer address is within a configured pool
ansible.builtin.assert:
that: lb_addr_in_range
success_msg: "LoadBalancer address {{ lb_ip_value }} is in a configured range"
fail_msg: >-
LoadBalancer address {{ lb_ip_value }} is not in a configured
range {{ verify_lb_ip_range }}
# Deactivated linter rules:
# - jinja[invalid]: As of version 6.6.0, ansible-lint complains that the input to ipwrap
# would be undefined. This will not be the case during playbook execution.
@@ -0,0 +1,354 @@
---
# Scenario-aware verification of cluster components and their live image tags.
# Scenario identity (verify_cni / verify_lb) and expected address range come
# from each scenario's verify-vars.yml, which is plain inventory data available
# to the verify play. Converge-time set_fact values are not persisted between
# the two Ansible processes, so they are never used here.
- name: Verify cluster components report expected versions
block:
- name: Get all nodes with their kubelet versions
kubernetes.core.k8s_info:
kind: node
kubeconfig: "{{ kubecfg_path }}"
register: verify_nodes
- name: Assert each node reports the expected Kubernetes version
ansible.builtin.assert:
that: item.status.nodeInfo.kubeletVersion == k3s_version
success_msg: "{{ item.metadata.name }} reports {{ k3s_version }}"
fail_msg: >-
{{ item.metadata.name }} reports
{{ item.status.nodeInfo.kubeletVersion }},
expected {{ k3s_version }}
loop: "{{ verify_nodes.resources }}"
loop_control:
label: "{{ item.metadata.name }}"
- name: Verify Flannel is the active CNI
when: verify_cni == 'flannel'
block:
- name: Assert every node reports Ready
ansible.builtin.assert:
that: item.status.conditions
| selectattr('type', 'equalto', 'Ready')
| map(attribute='status') | first | default('') == 'True'
success_msg: "{{ item.metadata.name }} is Ready"
fail_msg: "{{ item.metadata.name }} is not Ready"
loop: "{{ verify_nodes.resources }}"
loop_control:
label: "{{ item.metadata.name }} ready"
- name: Assert every node registered a node IP from its interface
# Each k3s node is launched with --node-ip derived from flannel_iface.
# Confirm every node carries a real InternalIP (not a loopback), which
# proves k3s bound to the cluster interface rather than defaulting to 127.0.0.1.
ansible.builtin.assert:
that: >-
(node_internal_ips | length) >= 1 and
(node_internal_ips | reject('eq', '127.0.0.1') | list | length) == node_internal_ips | length
success_msg: "{{ item.metadata.name }} is bound to {{ node_internal_ips | join(', ') }}"
fail_msg: >-
{{ item.metadata.name }} has no non-loopback InternalIP
(got: {{ node_internal_ips | join(', ') }})
vars:
node_internal_ips: >-
{{
(item.status.addresses | default([]))
| selectattr('type', 'equalto', 'InternalIP')
| map(attribute='address')
| list
}}
loop: "{{ verify_nodes.resources }}"
loop_control:
label: "{{ item.metadata.name }} InternalIP"
- name: Get any Calico namespaces with Flannel enabled
kubernetes.core.k8s_info:
kind: Namespace
name: calico-system
kubeconfig: "{{ kubecfg_path }}"
register: flannel_calico_absent
- name: Assert there is no Calico system namespace
ansible.builtin.assert:
that: flannel_calico_absent.resources | length == 0
success_msg: "No Calico present with Flannel"
fail_msg: "A Calico namespace exists alongside Flannel"
- name: Get the Cilium namespace with Flannel enabled
kubernetes.core.k8s_info:
kind: Namespace
name: cilium
kubeconfig: "{{ kubecfg_path }}"
register: flannel_cilium
- name: Assert the Cilium namespace is absent
ansible.builtin.assert:
that: flannel_cilium.resources | length == 0
success_msg: "No Cilium present with Flannel"
fail_msg: "A Cilium namespace exists alongside Flannel"
- name: Verify Calico is the active CNI
when: verify_cni == 'calico'
block:
- name: Get the Calico node DaemonSet image
kubernetes.core.k8s_info:
kind: DaemonSet
name: calico-node
namespace: calico-system
kubeconfig: "{{ kubecfg_path }}"
register: calico_node_ds
- name: Assert the Calico node image uses the expected tag
ansible.builtin.assert:
that:
- calico_node_ds.resources | length == 1
- calico_node_image | regex_search(':' ~ calico_tag) is not none
success_msg: "Calico node image uses tag {{ calico_tag }}"
fail_msg: >-
Calico node image {{ calico_node_image }},
expected {{ calico_tag }}
vars:
calico_node_image: "{{ calico_node_ds.resources[0].spec.template.spec.containers[0].image }}"
- name: Get Calico TigeraStatus for calico and apiserver
kubernetes.core.k8s_info:
api_version: operator.tigera.io/v1
kind: TigeraStatus
name: "{{ item }}"
kubeconfig: "{{ kubecfg_path }}"
register: calico_tigerastatus
loop:
- calico
- apiserver
loop_control:
label: "Tigerastatus/{{ item }}"
- name: Assert Calico TigeraStatus reports Available
ansible.builtin.assert:
that: >-
item.resources | length == 1 and
(item.resources[0].status.conditions
| selectattr('type', 'equalto', 'Available')
| map(attribute='status') | first | default('')) == 'True'
success_msg: "Tigerastatus {{ item.resources[0].metadata.name }} is Available"
fail_msg: "Tigerastatus is not Available"
loop: "{{ calico_tigerastatus.results }}"
loop_control:
label: "Tigerastatus Available"
- name: Get any Flannel DaemonSets with Calico enabled
kubernetes.core.k8s_info:
kind: DaemonSet
namespace: kube-flannel
kubeconfig: "{{ kubecfg_path }}"
register: no_flannel_ds
- name: Assert there are no Flannel DaemonSets
ansible.builtin.assert:
that: no_flannel_ds.resources | length == 0
success_msg: "No Flannel DaemonSet present with Calico"
fail_msg: "A Flannel DaemonSet exists alongside Calico"
- name: Verify Cilium is the active CNI
when: verify_cni == 'cilium'
block:
- name: Get the Cilium agent and operator images
kubernetes.core.k8s_info:
kind: "{{ item.kind }}"
name: "{{ item.name }}"
namespace: kube-system
kubeconfig: "{{ kubecfg_path }}"
register: cilium_info
loop:
- { kind: DaemonSet, name: cilium }
- { kind: Deployment, name: cilium-operator }
loop_control:
label: "{{ item.kind }}/{{ item.name }}"
- name: Assert Cilium agent and operator use the expected image tag
ansible.builtin.assert:
that:
- cilium_agent_image | regex_search(':' ~ cilium_tag) is not none
- cilium_operator_image | regex_search(':' ~ cilium_tag) is not none
success_msg: "Cilium agent and operator use {{ cilium_tag }}"
fail_msg: >-
Cilium agent {{ cilium_agent_image }},
operator {{ cilium_operator_image }},
expected {{ cilium_tag }}
vars:
cilium_agent_image: >-
{{ (cilium_info.results
| selectattr('resources', 'defined')
| map(attribute='resources')
| list
| map(attribute='0')
| selectattr('kind', 'equalto', 'DaemonSet')
| list)[0].spec.template.spec.containers[0].image }}
cilium_operator_image: >-
{{ (cilium_info.results
| selectattr('resources', 'defined')
| map(attribute='resources')
| list
| map(attribute='0')
| selectattr('kind', 'equalto', 'Deployment')
| list)[0].spec.template.spec.containers[0].image }}
- name: Get Hubble relay and UI deployments when enabled
kubernetes.core.k8s_info:
kind: Deployment
name: "{{ item }}"
namespace: kube-system
kubeconfig: "{{ kubecfg_path }}"
register: hubble_info
loop:
- hubble-relay
- hubble-ui
loop_control:
label: "Deployment/{{ item }}"
when: cilium_hubble | bool
- name: Assert Hubble components are Ready when enabled
ansible.builtin.assert:
that:
- item.resources | length == 1
- item.resources[0].status.readyReplicas | default(0) >= 1
success_msg: "Hubble deployment {{ item.resources[0].metadata.name }} is Ready"
fail_msg: "Hubble deployment is not Ready"
loop: "{{ hubble_info.results }}"
loop_control:
label: "Hubble deployment"
when: cilium_hubble | bool
- name: Get any Flannel DaemonSets with Cilium enabled
kubernetes.core.k8s_info:
kind: DaemonSet
namespace: kube-flannel
kubeconfig: "{{ kubecfg_path }}"
register: no_flannel_ds_cilium
- name: Assert there are no Flannel DaemonSets
ansible.builtin.assert:
that: no_flannel_ds_cilium.resources | length == 0
success_msg: "No Flannel DaemonSet present with Cilium"
fail_msg: "A Flannel DaemonSet exists alongside Cilium"
- name: Verify MetalLB is the active load balancer
when: verify_lb == 'metallb'
block:
- name: Get the MetalLB controller and speaker images
kubernetes.core.k8s_info:
kind: "{{ item.kind }}"
name: "{{ item.name }}"
namespace: metallb-system
kubeconfig: "{{ kubecfg_path }}"
register: metallb_info
until: metallb_info.resources | length > 0
retries: 15
delay: 10
loop:
- { kind: Deployment, name: controller }
- { kind: DaemonSet, name: speaker }
loop_control:
label: "{{ item.kind }}/{{ item.name }}"
- name: Fail with a clear message if MetalLB resources are missing
ansible.builtin.fail:
msg: >-
Did not find {{ item.kind | lower }} {{ item.name }} in
metallb-system. Expected MetalLB to be deployed in this
scenario (verify_lb: {{ verify_lb }}).
when: item.resources | length == 0
loop: "{{ metallb_info.results }}"
loop_control:
label: "{{ item.item.kind }}/{{ item.item.name }}"
- name: Assert MetalLB controller and speaker use the expected image tags
ansible.builtin.assert:
# regex_search returns a string or none; check for a match with `is not
# none` so the assertion is a real boolean (ansible-core 2.19 rejects
# string conditionals and `| bool` deprecates string coercion).
that:
- controller_image | regex_search(metal_lb_controller_tag_version) is not none
- speaker_image | regex_search(metal_lb_speaker_tag_version) is not none
success_msg: >-
MetalLB controller {{ metal_lb_controller_tag_version }},
speaker {{ metal_lb_speaker_tag_version }}
fail_msg: >-
MetalLB controller {{ controller_image }},
speaker {{ speaker_image }}
vars:
controller_image: >-
{{ (metallb_info.results
| selectattr('resources', 'defined')
| map(attribute='resources')
| list
| map(attribute='0')
| selectattr('kind', 'equalto', 'Deployment')
| list)[0].spec.template.spec.containers[0].image }}
speaker_image: >-
{{ (metallb_info.results
| selectattr('resources', 'defined')
| map(attribute='resources')
| list
| map(attribute='0')
| selectattr('kind', 'equalto', 'DaemonSet')
| list)[0].spec.template.spec.containers[0].image }}
- name: Verify kube-vip is the active load balancer
when: verify_lb == 'kube-vip'
block:
- name: Get the kube-vip and cloud provider images
kubernetes.core.k8s_info:
kind: "{{ item.kind }}"
name: "{{ item.name }}"
namespace: kube-system
kubeconfig: "{{ kubecfg_path }}"
register: kubevip_info
loop:
- { kind: DaemonSet, name: kube-vip-ds }
- { kind: Deployment, name: kube-vip-cloud-provider }
loop_control:
label: "{{ item.kind }}/{{ item.name }}"
- name: Assert the kube-vip and cloud provider image tags
ansible.builtin.assert:
that:
- kubevip_image | regex_search(':' ~ kube_vip_tag_version) is not none
- cloud_provider_image | regex_search(verify_kube_vip_cloud_provider_tag) is not none
success_msg: >-
kube-vip {{ kube_vip_tag_version }},
cloud provider {{ verify_kube_vip_cloud_provider_tag }}
fail_msg: >-
kube-vip {{ kubevip_image }},
cloud provider {{ cloud_provider_image }}
vars:
kubevip_image: >-
{{ (kubevip_info.results
| selectattr('resources', 'defined')
| map(attribute='resources')
| list
| map(attribute='0')
| selectattr('kind', 'equalto', 'DaemonSet')
| list)[0].spec.template.spec.containers[0].image }}
cloud_provider_image: >-
{{ (kubevip_info.results
| selectattr('resources', 'defined')
| map(attribute='resources')
| list
| map(attribute='0')
| selectattr('kind', 'equalto', 'Deployment')
| list)[0].spec.template.spec.containers[0].image }}
- name: Get the MetalLB namespace with kube-vip enabled
kubernetes.core.k8s_info:
kind: Namespace
name: metallb-system
kubeconfig: "{{ kubecfg_path }}"
register: metallb_absent
- name: Assert the MetalLB namespace does not exist
ansible.builtin.assert:
that: metallb_absent.resources | length == 0
success_msg: "MetalLB is not installed with kube-vip"
fail_msg: "MetalLB namespace exists alongside kube-vip"
+6
View File
@@ -0,0 +1,6 @@
---
# Durable verify inputs for the single_node (flannel + MetalLB) scenario.
verify_cni: flannel
verify_lb: metallb
verify_lb_ip_range:
- 192.168.30.91-192.168.30.99
+21 -1
View File
@@ -2,9 +2,29 @@
- name: Reboot k3s_cluster
hosts: k3s_cluster
gather_facts: true
# Stagger the reboot across the cluster when concurrent_reboots is set.
# Defaults to '100%' so the whole cluster reboots at once (backward compatible).
serial: "{{ concurrent_reboots | default('100%') }}"
tasks:
- name: Reboot the nodes (and Wait upto 5 mins max)
- name: >-
{{
'Reboot all nodes at once'
if (concurrent_reboots is not defined)
else 'Reboot nodes with concurrency of ' ~ concurrent_reboots
}}
become: true
ansible.builtin.reboot:
reboot_command: "{{ custom_reboot_command | default(omit) }}"
reboot_timeout: 300
test_command: >-
{{ 'kubectl get nodes' if 'master' in group_names else 'whoami' }}
- name: Optional wait before rebooting the next batch of nodes
ansible.builtin.pause:
seconds: "{{ wait_seconds_after_reboot | int }}"
when: >-
concurrent_reboots is defined and
wait_seconds_after_reboot is defined and
wait_seconds_after_reboot | int > 0
+3 -3
View File
@@ -1,6 +1,6 @@
ansible-core>=2.16.2
jmespath>=1.0.1
jsonpatch>=1.33
ansible-core>=2.19.14
jmespath>=1.1.0
jsonpatch>=1.35
kubernetes>=29.0.0
molecule-plugins[vagrant]
molecule>=6.0.3
+7 -7
View File
@@ -2,11 +2,11 @@
# This file is autogenerated by pip-compile with Python 3.11
# by the following command:
#
# pip-compile requirements.in
# pip-compile --output-file=requirements.txt requirements.in
#
ansible-compat==4.1.11
# via molecule
ansible-core==2.18.0
ansible-core==2.19.14
# via
# -r requirements.in
# ansible-compat
@@ -53,11 +53,11 @@ jinja2==3.1.3
# via
# ansible-core
# molecule
jmespath==1.0.1
jmespath==1.1.0
# via -r requirements.in
jsonpatch==1.33
jsonpatch==1.35
# via -r requirements.in
jsonpointer==2.4
jsonpointer==3.2.1
# via jsonpatch
jsonschema==4.21.1
# via
@@ -77,7 +77,7 @@ molecule==6.0.3
# via
# -r requirements.in
# molecule-plugins
molecule-plugins[vagrant]==23.5.3
molecule-plugins[vagrant]==23.6.0
# via -r requirements.in
netaddr==0.10.1
# via -r requirements.in
@@ -146,7 +146,7 @@ rsa==4.9
# via google-auth
ruamel-yaml==0.18.5
# via pre-commit-hooks
ruamel-yaml-clib==0.2.8
ruamel-yaml-clib==0.2.15
# via ruamel-yaml
six==1.16.0
# via
+1 -1
View File
@@ -11,7 +11,7 @@ ExecStartPre=-/sbin/modprobe overlay
ExecStart=/usr/local/bin/k3s agent \
--server https://{{ apiserver_endpoint | ansible.utils.ipwrap }}:6443 \
{% if is_pxe_booted | default(false) %}--snapshotter native \
{% endif %}--token {{ hostvars[groups[group_name_master | default('master')][0]]['token'] | default(k3s_token) }} \
{% endif %}--token {{ hostvars[groups[group_name_master | default('master')][0]]['token'] | default(k3s_token) | replace( '%', '%%' ) }} \
{{ extra_agent_args }}
KillMode=process
Delegate=yes
+16 -6
View File
@@ -7,8 +7,9 @@ group_name_master: master
kube_vip_arp: true
kube_vip_iface:
kube_vip_cloud_provider_tag_version: main
kube_vip_tag_version: v0.7.2
kube_vip_endpoint:
kube_vip_cloud_provider_tag_version: v0.0.12
kube_vip_tag_version: v1.2.3
kube_vip_bgp: false
kube_vip_bgp_routerid: 127.0.0.1
@@ -19,21 +20,30 @@ kube_vip_bgp_peeras: "64512"
kube_vip_bgp_peers: []
kube_vip_bgp_peers_groups: ['k3s_master']
metal_lb_controller_tag_version: v0.14.3
metal_lb_speaker_tag_version: v0.14.3
kube_vip_enabled: true
metal_lb_enabled: true
metal_lb_controller_tag_version: v0.16.0
metal_lb_speaker_tag_version: v0.16.0
metal_lb_type: native
# Shared retry/delay for remote manifest and asset downloads. The CI runner's
# resolver intermittently times out on GitHub-hosted domains (helm.cilium.io,
# raw.githubusercontent.com, github.com), so retry transient DNS/network failures.
download_retries: 5
download_delay: 10
retry_count: 20
# yamllint disable rule:line-length
server_init_args: >-
{% if groups[group_name_master | default('master')] | length > 1 %}
{% if ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname'] %}
{% if ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname'] %}
--cluster-init
{% else %}
--server https://{{ hostvars[groups[group_name_master | default('master')][0]].k3s_node_ip | split(",") | first | ansible.utils.ipwrap }}:6443
{% endif %}
--token {{ k3s_token }}
--token {{ k3s_token | replace('%', '%%') }}
{% endif %}
{{ extra_server_args }}
+28 -4
View File
@@ -78,25 +78,49 @@ argument_specs:
- automatically at runtime.
default: ~
kube_vip_endpoint:
description:
- Overrides the address kube-vip binds/listens on internally, which
- can differ from the announced apiserver_endpoint for complex
- routing and site-to-site tunnels.
- Defaults to apiserver_endpoint and is used to derive the kube-vip
- subnet.
default: ~
kube_vip_tag_version:
description: Image tag for kube-vip
default: v0.7.2
default: v1.2.3
kube_vip_cloud_provider_tag_version:
description: Tag for kube-vip-cloud-provider manifest when enabled
default: main
default: v0.0.12
kube_vip_enabled:
description:
- Enable installing kube-vip.
- Covers both the control-plane VIP and the service load balancer.
- Set false to skip kube-vip entirely, for example on a single node or when an external load balancer is used.
default: true
type: bool
kube_vip_lb_ip_range:
description: IP range for kube-vip load balancer
default: ~
metal_lb_enabled:
description:
- Enable installing MetalLB for service load balancing.
- Set false to skip MetalLB, for example when an external load balancer is used.
default: true
type: bool
metal_lb_controller_tag_version:
description: Image tag for MetalLB
default: v0.14.3
default: v0.16.0
metal_lb_speaker_tag_version:
description: Image tag for MetalLB
default: v0.14.3
default: v0.16.0
metal_lb_type:
choices:
@@ -83,7 +83,7 @@
no_log: true
register: k3s_input_firewall_rules
- name: Ping the primary Kubernetes API address from {{ ansible_hostname }}
- name: Ping the primary Kubernetes API address from {{ ansible_facts.hostname }}
ansible.builtin.command:
argv:
- ping
@@ -97,7 +97,7 @@
no_log: true
register: k3s_primary_api_ping
- name: Probe primary Kubernetes API from {{ ansible_hostname }}
- name: Probe primary Kubernetes API from {{ ansible_facts.hostname }}
ansible.builtin.wait_for:
host: "{{ hostvars[groups[group_name_master | default('master')][0]].k3s_node_ip | split(',') | first }}"
port: 6443
@@ -122,7 +122,7 @@
become: false
ansible.builtin.template:
src: content.j2
dest: "{{ log_destination }}/k3s-init@{{ ansible_hostname }}.log"
dest: "{{ log_destination }}/k3s-init@{{ ansible_facts['hostname'] }}.log"
mode: "0644"
vars:
content: |
@@ -137,7 +137,7 @@
become: false
ansible.builtin.template:
src: content.j2
dest: "{{ log_destination }}/network@{{ ansible_hostname }}.log"
dest: "{{ log_destination }}/network@{{ ansible_facts['hostname'] }}.log"
mode: "0644"
vars:
content: |
+1 -1
View File
@@ -25,7 +25,7 @@
ansible.builtin.command:
cmd: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }} get node
{{ hostvars[joining_master].ansible_hostname }}
{{ hostvars[joining_master].ansible_facts['hostname'] }}
delegate_to: "{{ groups[group_name_master | default('master')][0] }}"
register: joined_master
until: joined_master.rc == 0
+11 -4
View File
@@ -6,16 +6,22 @@
owner: root
group: root
mode: "0644"
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- name: Download vip cloud provider manifest to first master
ansible.builtin.get_url:
url: https://raw.githubusercontent.com/kube-vip/kube-vip-cloud-provider/{{ kube_vip_cloud_provider_tag_version | default('main') }}/manifest/kube-vip-cloud-controller.yaml # noqa yaml[line-length]
url: https://raw.githubusercontent.com/kube-vip/kube-vip-cloud-provider/{{ kube_vip_cloud_provider_tag_version | default('v0.0.12') }}/manifest/kube-vip-cloud-controller.yaml # noqa yaml[line-length]
dest: /var/lib/rancher/k3s/server/manifests/kube-vip-cloud-controller.yaml
owner: root
group: root
mode: "0644"
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
register: kube_vip_manifest_download
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
until: kube_vip_manifest_download is succeeded
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- name: Copy kubevip configMap manifest to first master
ansible.builtin.template:
@@ -24,4 +30,5 @@
owner: root
group: root
mode: "0644"
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
+24 -10
View File
@@ -24,15 +24,21 @@
- name: Deploy vip manifest
ansible.builtin.include_tasks: vip.yml
when: kube_vip_enabled
- name: Deploy metallb manifest
ansible.builtin.include_tasks: metallb.yml
tags: metallb
when: kube_vip_lb_ip_range is not defined and (not cilium_bgp or cilium_iface is not defined)
# Deploy MetalLB unless explicitly disabled, kube-vip owns the load balancer
# IP range, or Cilium BGP is enabled (Cilium then provides its own load
# balancing). The cilium_bgp default keeps this safe when Cilium variables are
# not in scope at all (#644) while still deploying MetalLB when a non-BGP
# Cilium CNI is in use.
when: metal_lb_enabled and kube_vip_lb_ip_range is not defined and not (cilium_bgp | default(false) | bool)
- name: Deploy kube-vip manifest
ansible.builtin.include_tasks: kube-vip.yml
tags: kubevip
when: kube_vip_lb_ip_range is defined
when: kube_vip_enabled and kube_vip_lb_ip_range is defined
- name: Initialize and verify the K3s control plane
any_errors_fatal: true
@@ -98,7 +104,7 @@
expected_control_plane_names: >-
{{
groups[group_name_master | default('master')]
| map('extract', hostvars, 'ansible_hostname')
| map('extract', hostvars, ['ansible_facts', 'hostname'])
| list
}}
@@ -118,10 +124,18 @@
always:
- name: Save logs of k3s-init.service
ansible.builtin.include_tasks: fetch_k3s_init_logs.yml
when: log_destination
# The env lookup default must be an empty string, not the boolean
# False. The `!= ''` comparison below always evaluates to a bool, so
# ansible-core 2.19 never sees a non-boolean conditional and no boolean
# default is needed. With a boolean default, an unset ANSIBLE_K3S_LOG_DIR
# leaves log_destination as the bool False, and `False != ''` is True
# because False == 0. The guard would then always pass, running the
# diagnostics on every provisioning run and creating a directory
# literally named 'False' in the operator's working directory.
when: log_destination | default('') != ''
vars:
log_destination: >-
{{ lookup('ansible.builtin.env', 'ANSIBLE_K3S_LOG_DIR', default=False) }}
{{ lookup('ansible.builtin.env', 'ANSIBLE_K3S_LOG_DIR', default='') }}
- name: Kill the temporary service used for initialization
ansible.builtin.systemd:
name: k3s-init
@@ -174,24 +188,24 @@
- name: Create directory .kube
ansible.builtin.file:
path: "{{ ansible_user_dir }}/.kube"
path: "{{ ansible_facts['user_dir'] }}/.kube"
state: directory
owner: "{{ ansible_user_id }}"
owner: "{{ ansible_facts['user_id'] }}"
mode: u=rwx,g=rx,o=
- name: Copy config file to user home directory
ansible.builtin.copy:
src: /etc/rancher/k3s/k3s.yaml
dest: "{{ ansible_user_dir }}/.kube/config"
dest: "{{ ansible_facts['user_dir'] }}/.kube/config"
remote_src: true
owner: "{{ ansible_user_id }}"
owner: "{{ ansible_facts['user_id'] }}"
mode: u=rw,g=,o=
- name: Configure kubectl cluster to {{ endpoint_url }}
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }} config set-cluster default
--server={{ endpoint_url }}
--kubeconfig {{ ansible_user_dir }}/.kube/config
--kubeconfig {{ ansible_facts['user_dir'] }}/.kube/config
changed_when: true
vars:
endpoint_url: >-
+39 -3
View File
@@ -6,7 +6,8 @@
owner: root
group: root
mode: "0644"
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- name: "Download to first master: manifest for metallb-{{ metal_lb_type }}"
ansible.builtin.get_url:
@@ -15,7 +16,12 @@
owner: root
group: root
mode: "0644"
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
register: metallb_manifest_download
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
until: metallb_manifest_download is succeeded
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- name: Set image versions in manifest for metallb-{{ metal_lb_type }}
ansible.builtin.replace:
@@ -27,4 +33,34 @@
to: metallb/speaker:{{ metal_lb_speaker_tag_version }}
loop_control:
label: "{{ item.change }} => {{ item.to }}"
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- name: Read back MetalLB manifest from first master
ansible.builtin.slurp:
src: /var/lib/rancher/k3s/server/manifests/metallb-crds.yaml
register: metallb_manifest
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- name: Check the MetalLB speaker image reference is present
ansible.builtin.set_fact:
metallb_manifest_has_speaker_tag: >-
{{
('quay.io/metallb/speaker:' ~ metal_lb_speaker_tag_version) in
(metallb_manifest.content | default('') | b64decode)
}}
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- name: Fail if MetalLB speaker tag was not applied to the manifest
ansible.builtin.fail:
msg: >-
MetalLB speaker image reference
quay.io/metallb/speaker:{{ metal_lb_speaker_tag_version }}
was not found in the downloaded manifest. The upstream image
reference may have changed.
when:
- >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- not metallb_manifest_has_speaker_tag
+9 -6
View File
@@ -10,16 +10,18 @@
owner: root
group: root
mode: "0644"
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- name: Download vip rbac manifest to first master
ansible.builtin.get_url:
url: https://kube-vip.io/manifests/rbac.yaml
- name: Copy kube-vip RBAC manifest to first master
ansible.builtin.template:
src: kube-vip-rbac.yaml.j2
dest: /var/lib/rancher/k3s/server/manifests/vip-rbac.yaml
owner: root
group: root
mode: "0644"
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
- name: Copy vip manifest to first master
ansible.builtin.template:
@@ -28,4 +30,5 @@
owner: root
group: root
mode: "0644"
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
@@ -0,0 +1,48 @@
# Repository-owned kube-vip RBAC derived from the official v1.2.2
# `kube-vip manifest rbac` output (run with `--inCluster`).
# Source: https://github.com/kube-vip/kube-vip/blob/v1.2.2/pkg/kubevip/config_generator.go
# Permissions are kept exactly as broad as the upstream manifest.
apiVersion: v1
kind: ServiceAccount
metadata:
name: kube-vip
namespace: kube-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
annotations:
rbac.authorization.kubernetes.io/autoupdate: "true"
name: system:kube-vip-role
rules:
- apiGroups: [""]
resources: ["services/status"]
verbs: ["update"]
- apiGroups: [""]
resources: ["services", "endpoints"]
verbs: ["list", "get", "watch", "update"]
- apiGroups: [""]
resources: ["nodes"]
verbs: ["list", "get", "watch", "update", "patch"]
- apiGroups: ["coordination.k8s.io"]
resources: ["leases"]
verbs: ["list", "get", "watch", "update", "create"]
- apiGroups: ["discovery.k8s.io"]
resources: ["endpointslices"]
verbs: ["list", "get", "watch", "update"]
- apiGroups: [""]
resources: ["pods"]
verbs: ["list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: system:kube-vip-binding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: system:kube-vip-role
subjects:
- kind: ServiceAccount
name: kube-vip
namespace: kube-system
+5 -5
View File
@@ -36,8 +36,8 @@ spec:
- name: vip_interface
value: {{ kube_vip_iface }}
{% endif %}
- name: vip_cidr
value: "{{ apiserver_endpoint | ansible.utils.ipsubnet | ansible.utils.ipaddr('prefix') }}"
- name: vip_subnet
value: "{{ (kube_vip_endpoint | default(apiserver_endpoint, true)) | ansible.utils.ipsubnet | ansible.utils.ipaddr('prefix') }}"
- name: cp_enable
value: "true"
- name: cp_namespace
@@ -55,15 +55,15 @@ spec:
- name: vip_retryperiod
value: "2"
- name: address
value: {{ apiserver_endpoint }}
value: {{ kube_vip_endpoint | default(apiserver_endpoint, true) }}
{% if kube_vip_bgp | default(false) | bool %}
{% if kube_vip_bgp_routerid is defined %}
- name: bgp_routerid
value: "{{ kube_vip_bgp_routerid }}"
{% endif %}
{% if _kube_vip_bgp_peers | length > 0 %}
- name: bgppeers
value: "{{ _kube_vip_bgp_peers | map(attribute='peer_address') | zip(_kube_vip_bgp_peers| map(attribute='peer_asn')) | map('join', ',') | join(':') }}" # yamllint disable-line rule:line-length
- name: bgp_peers
value: "{{ _kube_vip_bgp_peers | map(attribute='peer_address') | zip(_kube_vip_bgp_peers | map(attribute='peer_asn')) | map('join', ':') | join(',') }}" # yamllint disable-line rule:line-length
{% else %}
{% if kube_vip_bgp_as is defined %}
- name: bgp_as
+14 -2
View File
@@ -9,7 +9,7 @@ calico_ebpf: false
calico_encapsulation: VXLANCrossSubnet
calico_natOutgoing: Enabled # noqa var-naming
calico_nodeSelector: all() # noqa var-naming
calico_tag: v3.27.2
calico_tag: v3.32.1
cilium_bgp: false
cilium_exportPodCIDR: true # noqa var-naming
@@ -18,8 +18,18 @@ cilium_bgp_peer_asn: 64512
cilium_bgp_neighbors: []
cilium_bgp_neighbors_groups: ['k3s_all']
cilium_bgp_lb_cidr: 192.168.31.0/24
cilium_envoy: true
cilium_hubble: true
cilium_mode: native
cilium_tag: v1.20.0
cilium_cli_tag: v0.19.7
# Shared retry/delay for remote manifest, asset downloads, and waiting on
# Kubernetes resources. The CI runner's resolver intermittently times out on
# GitHub-hosted domains and the kube API can transiently return
# ServiceUnavailable, so retry transient DNS/network/API failures.
download_retries: 5
download_delay: 10
cluster_cidr: 10.52.0.0/16
enable_bpf_masquerade: true
@@ -28,5 +38,7 @@ group_name_master: master
metal_lb_mode: layer2
metal_lb_available_timeout: 240s
metal_lb_controller_tag_version: v0.14.3
metal_lb_controller_tag_version: v0.16.0
metal_lb_enabled: true
metal_lb_interfaces: []
metal_lb_ip_range: 192.168.30.80-192.168.30.90
+30 -3
View File
@@ -43,7 +43,7 @@ argument_specs:
calico_tag:
description: Calico version tag
default: v3.27.2
default: v3.32.1
cilium_bgp:
description:
@@ -93,12 +93,24 @@ argument_specs:
default: ~
cilium_mode:
description: Inner-node communication mode
description:
- Inner-node communication mode.
- Cilium accepts `native` or `tunnel`. `routed` is accepted as a
legacy alias and is mapped to `tunnel` at install time.
default: native
choices:
- native
- tunnel
- routed
cilium_tag:
description: Cilium version tag
default: v1.20.0
cilium_cli_tag:
description: Cilium CLI version tag
default: v0.19.7
cluster_cidr:
description: Inner-cluster IP range
default: 10.52.0.0/16
@@ -121,6 +133,13 @@ argument_specs:
description: IP range for kube-vip load balancer
default: ~
metal_lb_enabled:
description:
- Enable installing the MetalLB pool for service load balancing.
- Set false to skip MetalLB, for example when an external load balancer is used.
default: true
type: bool
metal_lb_available_timeout:
description: Wait for MetalLB resources
default: 240s
@@ -129,9 +148,17 @@ argument_specs:
description: MetalLB ip range for load balancer
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:
description: Image tag for MetalLB
default: v0.14.3
default: v0.16.0
metal_lb_mode:
description: Metallb mode
+102 -50
View File
@@ -1,6 +1,7 @@
---
- name: Deploy Calico to cluster
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
when: >-
ansible_facts.hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']
run_once: true
block:
- name: Create manifests directory on first master
@@ -11,6 +12,18 @@
group: root
mode: "0755"
- name: "Download to first master: Calico CRD bundle for {{ calico_tag }}"
ansible.builtin.get_url:
url: https://raw.githubusercontent.com/projectcalico/calico/{{ calico_tag }}/manifests/v1_crd_projectcalico_org.yaml # noqa yaml[line-length]
dest: /tmp/k3s/v1_crd_projectcalico_org.yaml
owner: root
group: root
mode: "0755"
register: calico_crd_download
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
until: calico_crd_download is succeeded
- name: "Download to first master: manifest for Tigera Operator and Calico CRDs"
ansible.builtin.get_url:
url: https://raw.githubusercontent.com/projectcalico/calico/{{ calico_tag }}/manifests/tigera-operator.yaml
@@ -18,6 +31,52 @@
owner: root
group: root
mode: "0755"
register: tigera_operator_download
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
until: tigera_operator_download is succeeded
- name: Apply Calico CRD bundle with server-side apply
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }}
apply --server-side --force-conflicts
-f /tmp/k3s/v1_crd_projectcalico_org.yaml
register: apply_crds
changed_when: >-
'created' in apply_crds.stdout or
'configured' in apply_crds.stdout or
'applied' in apply_crds.stdout
failed_when: apply_crds.rc != 0
- name: Apply Tigera Operator manifest idempotently with server-side apply
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }}
apply --server-side --force-conflicts
-f /tmp/k3s/tigera-operator.yaml
register: apply_operator
changed_when: >-
'created' in apply_operator.stdout or
'configured' in apply_operator.stdout or
'applied' in apply_operator.stdout
failed_when: apply_operator.rc != 0
- name: Wait for Tigera Operator and managed CRDs to become available
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }} wait {{ item.type }}/{{ item.name }}
{% if item.namespace is defined %}--namespace='{{ item.namespace }}'{% endif %}
--for=condition={{ item.condition }}
--timeout=30s
register: tigera_result
changed_when: false
until: tigera_result is succeeded
retries: 7
delay: 7
with_items:
- { name: tigera-operator, type: deployment, namespace: tigera-operator, condition: Available=True }
- { name: installations.operator.tigera.io, type: crd, condition: Established }
- { name: apiservers.operator.tigera.io, type: crd, condition: Established }
loop_control:
label: "{{ item.type }}/{{ item.name }}"
- name: Copy Calico custom resources manifest to first master
ansible.builtin.template:
@@ -27,53 +86,16 @@
group: root
mode: "0755"
- name: Deploy or replace Tigera Operator
block:
- name: Deploy Tigera Operator
ansible.builtin.command:
cmd: "{{ k3s_kubectl_binary | default('k3s kubectl') }} create -f /tmp/k3s/tigera-operator.yaml"
register: create_operator
changed_when: "'created' in create_operator.stdout"
failed_when: "'Error' in create_operator.stderr and 'already exists' not in create_operator.stderr"
rescue:
- name: Replace existing Tigera Operator
ansible.builtin.command:
cmd: "{{ k3s_kubectl_binary | default('k3s kubectl') }} replace -f /tmp/k3s/tigera-operator.yaml"
register: replace_operator
changed_when: "'replaced' in replace_operator.stdout"
failed_when: "'Error' in replace_operator.stderr"
- name: Wait for Tigera Operator resources
- name: Apply Calico custom resources
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }} wait {{ item.type }}/{{ item.name }}
--namespace='tigera-operator'
--for=condition=Available=True
--timeout=30s
register: tigera_result
changed_when: false
until: tigera_result is succeeded
retries: 7
delay: 7
with_items:
- { name: tigera-operator, type: deployment }
loop_control:
label: "{{ item.type }}/{{ item.name }}"
- name: Deploy Calico custom resources
block:
- name: Deploy custom resources for Calico
ansible.builtin.command:
cmd: "{{ k3s_kubectl_binary | default('k3s kubectl') }} create -f /tmp/k3s/custom-resources.yaml"
register: create_cr
changed_when: "'created' in create_cr.stdout"
failed_when: "'Error' in create_cr.stderr and 'already exists' not in create_cr.stderr"
rescue:
- name: Apply new Calico custom resource manifest
ansible.builtin.command:
cmd: "{{ k3s_kubectl_binary | default('k3s kubectl') }} apply -f /tmp/k3s/custom-resources.yaml"
register: apply_cr
changed_when: "'configured' in apply_cr.stdout or 'created' in apply_cr.stdout"
failed_when: "'Error' in apply_cr.stderr"
{{ k3s_kubectl_binary | default('k3s kubectl') }}
apply -f /tmp/k3s/custom-resources.yaml
register: apply_cr
changed_when: >-
'configured' in apply_cr.stdout or
'created' in apply_cr.stdout or
'unchanged' in apply_cr.stdout
failed_when: apply_cr.rc != 0
- name: Wait for Calico system resources to be available
ansible.builtin.command: >-
@@ -91,8 +113,8 @@
register: cr_result
changed_when: false
until: cr_result is succeeded
retries: 30
delay: 7
retries: 60
delay: 10
with_items:
- { name: calico-typha, type: deployment, namespace: calico-system }
- { name: calico-kube-controllers, type: deployment, namespace: calico-system }
@@ -104,10 +126,40 @@
type: daemonset
selector: k8s-app=calico-node
namespace: calico-system
- { name: calico-apiserver, type: deployment, namespace: calico-apiserver }
loop_control:
label: "{{ item.type }}/{{ item.name }}"
# The Calico API server is an optional add-on for managing Calico policy via
# kubectl (the projectcalico.org/v3 Kubernetes API). It is not required for
# Calico CNI data plane operation and does not reconcile on K3s with recent
# Calico releases, so it is treated as best-effort here.
- name: Wait for the optional Calico API server (best effort)
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }} wait deployment/calico-apiserver
--namespace=calico-apiserver --for=condition=Available --timeout=30s
register: cr_apiserver
changed_when: false
until: cr_apiserver is succeeded
retries: 30
delay: 10
ignore_errors: true
- name: Verify Calico TigeraStatus reports Available
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }}
get tigerastatus {{ item }}
-o jsonpath='{.status.conditions[?(@.type=="Available")].status}'
register: tigera_status
changed_when: false
until: tigera_status.stdout | trim == 'True'
retries: 30
delay: 7
failed_when: tigera_status.rc != 0 or (tigera_status.stdout | trim) != 'True'
with_items:
- calico
loop_control:
label: "tigerastatus/{{ item }}"
- name: Patch Felix configuration for eBPF mode
ansible.builtin.command:
cmd: >
+63 -32
View File
@@ -30,26 +30,13 @@
}}
when: cilium_cli_installed.rc == 0
- name: Get latest stable Cilium CLI version file
ansible.builtin.get_url:
url: https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt
dest: /tmp/k3s/cilium-cli-stable.txt
owner: root
group: root
mode: "0755"
- name: Read Cilium CLI stable version from file
ansible.builtin.command: cat /tmp/k3s/cilium-cli-stable.txt
register: cli_ver
changed_when: false
- name: Log installed Cilium CLI version
ansible.builtin.debug:
msg: "Installed Cilium CLI version: {{ installed_cli_version | default('Not installed') }}"
- name: Log latest stable Cilium CLI version
- name: Log pinned Cilium CLI version
ansible.builtin.debug:
msg: "Latest Cilium CLI version: {{ cli_ver.stdout }}"
msg: "Pinned Cilium CLI version: {{ cilium_cli_tag }}"
- name: Determine if Cilium CLI needs installation or update
ansible.builtin.set_fact:
@@ -57,7 +44,7 @@
{{
cilium_cli_installed.rc != 0 or
(cilium_cli_installed.rc == 0 and
installed_cli_version != cli_ver.stdout)
installed_cli_version != cilium_cli_tag)
}}
- name: Install or update Cilium CLI
@@ -78,7 +65,11 @@
- .tar.gz
- .tar.gz.sha256sum
vars:
cilium_base_url: https://github.com/cilium/cilium-cli/releases/download/{{ cli_ver.stdout }}
cilium_base_url: https://github.com/cilium/cilium-cli/releases/download/{{ cilium_cli_tag }}
register: cilium_cli_download
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
until: cilium_cli_download is succeeded
- name: Verify the downloaded tarball
ansible.builtin.shell: |
@@ -156,6 +147,13 @@
Target Cilium version: {{ cilium_tag }},
Update needed: {{ cilium_needs_update }}
- name: Map the legacy routed mode to Cilium tunnel mode
ansible.builtin.set_fact:
# Cilium 1.20 accepts `native` or `tunnel`. `routed` was the name used
# by older releases and is kept only as a backward-compatible alias.
cilium_routing_mode: >-
{{ 'tunnel' if cilium_mode == 'routed' else cilium_mode }}
- name: Install Cilium
ansible.builtin.command: >-
{% if cilium_installed.rc != 0 %}
@@ -167,27 +165,35 @@
--helm-set operator.replicas="1"
{{ '--helm-set devices=' + cilium_iface if cilium_iface != 'auto' else '' }}
--helm-set ipam.operator.clusterPoolIPv4PodCIDRList={{ cluster_cidr }}
{% if cilium_mode == "native" or (cilium_bgp and cilium_exportPodCIDR != 'false') %}
{% if cilium_routing_mode == "native" or (cilium_bgp and cilium_exportPodCIDR != 'false') %}
--helm-set ipv4NativeRoutingCIDR={{ cluster_cidr }}
{% endif %}
--helm-set k8sServiceHost="127.0.0.1"
--helm-set k8sServicePort="6444"
--helm-set routingMode={{ cilium_mode }}
--helm-set autoDirectNodeRoutes={{ "true" if cilium_mode == "native" else "false" }}
--helm-set routingMode={{ cilium_routing_mode }}
--helm-set autoDirectNodeRoutes={{ "true" if cilium_routing_mode == "native" else "false" }}
--helm-set kubeProxyReplacement={{ kube_proxy_replacement }}
--helm-set bpf.masquerade={{ enable_bpf_masquerade }}
--helm-set bgpControlPlane.enabled={{ cilium_bgp | default("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.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 %}
--helm-set bpf.loadBalancer.algorithm={{ bpf_lb_algorithm }}
--helm-set bpf.loadBalancer.mode={{ bpf_lb_mode }}
--helm-set loadBalancer.algorithm={{ bpf_lb_algorithm }}
--helm-set loadBalancer.mode={{ bpf_lb_mode }}
{% endif %}
environment:
KUBECONFIG: "{{ ansible_user_dir }}/.kube/config"
KUBECONFIG: "{{ ansible_facts['user_dir'] }}/.kube/config"
register: cilium_install_result
changed_when: cilium_install_result.rc == 0
# cilium install/upgrade fetches the Helm chart from helm.cilium.io, which is
# fronted by GitHub Pages and can transiently fail DNS resolution through the
# host resolver (intermittent "lookup helm.cilium.io ... i/o timeout"). Retry
# so a transient name/network failure does not abort the whole converge play.
until: cilium_install_result.rc == 0
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
when: cilium_installed.rc != 0 or cilium_needs_update
- name: Wait for Cilium resources
@@ -218,6 +224,16 @@
when: >-
not item.check_hubble | default(false) or (item.check_hubble | default(false) and cilium_hubble)
- name: Wait for Cilium status to be healthy
ansible.builtin.command: cilium status --wait
environment:
KUBECONFIG: "{{ ansible_facts['user_dir'] }}/.kube/config"
register: cilium_status
changed_when: false
until: cilium_status.rc == 0
retries: 30
delay: 7
- name: Configure Cilium BGP
when: cilium_bgp
block:
@@ -233,23 +249,38 @@
group: root
mode: "0755"
- name: Preflight validate rendered BGP manifests against installed CRDs
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }}
apply --dry-run=server -f /tmp/k3s/cilium-bgp.yaml
register: preflight_cr
changed_when: false
failed_when: preflight_cr.rc != 0
- name: Apply BGP manifests
ansible.builtin.command:
cmd: "{{ k3s_kubectl_binary | default('k3s kubectl') }} apply -f /tmp/k3s/cilium-bgp.yaml"
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }}
apply -f /tmp/k3s/cilium-bgp.yaml
register: apply_cr
changed_when: "'configured' in apply_cr.stdout or 'created' in apply_cr.stdout"
failed_when: "'is invalid' in apply_cr.stderr"
ignore_errors: true
failed_when: apply_cr.rc != 0
- name: Print error message if BGP manifests application fails
ansible.builtin.debug:
msg: "{{ apply_cr.stderr }}"
when: "'is invalid' in apply_cr.stderr"
- name: Remove deprecated CiliumBGPPeeringPolicy after v2 resources are accepted
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }}
delete CiliumBGPPeeringPolicy.cilium.io 01-bgp-peering-policy
register: delete_old_policy
changed_when: "'deleted' in delete_old_policy.stdout"
# The policy (and possibly its CRD) may already be absent; this is
# intentionally tolerated, not a command whose failure must be hidden.
failed_when: false
- name: Test for BGP config resources
ansible.builtin.command: "{{ item }}"
loop:
- "{{ k3s_kubectl_binary | default('k3s kubectl') }} get CiliumBGPPeeringPolicy.cilium.io"
- "{{ k3s_kubectl_binary | default('k3s kubectl') }} get CiliumBGPClusterConfig.cilium.io"
- "{{ k3s_kubectl_binary | default('k3s kubectl') }} get CiliumBGPPeerConfig.cilium.io"
- "{{ k3s_kubectl_binary | default('k3s kubectl') }} get CiliumBGPAdvertisement.cilium.io"
- "{{ k3s_kubectl_binary | default('k3s kubectl') }} get CiliumLoadBalancerIPPool.cilium.io"
changed_when: false
loop_control:
+6 -1
View File
@@ -12,7 +12,12 @@
- name: Deploy metallb pool
ansible.builtin.include_tasks: metallb.yml
tags: metallb
when: kube_vip_lb_ip_range is not defined and (not cilium_bgp or cilium_iface is not defined)
# Deploy MetalLB unless explicitly disabled, kube-vip owns the load balancer
# IP range, or Cilium BGP is enabled (Cilium then provides its own load
# balancing). The cilium_bgp default keeps this safe when Cilium variables are
# not in scope at all (#644) while still deploying MetalLB when a non-BGP
# Cilium CNI is in use.
when: metal_lb_enabled and kube_vip_lb_ip_range is not defined and not (cilium_bgp | default(false) | bool)
- name: Remove tmp directory used for manifests
ansible.builtin.file:
+32 -14
View File
@@ -3,7 +3,7 @@
ansible.builtin.file:
path: /tmp/k3s
state: directory
owner: "{{ ansible_user_id }}"
owner: "{{ ansible_facts['user_id'] }}"
mode: "0755"
with_items: "{{ groups[group_name_master | default('master')] }}"
run_once: true
@@ -33,15 +33,21 @@
ansible.builtin.template:
src: metallb.crs.j2
dest: /tmp/k3s/metallb-crs.yaml
owner: "{{ ansible_user_id }}"
owner: "{{ ansible_facts['user_id'] }}"
mode: "0755"
with_items: "{{ groups[group_name_master | default('master')] }}"
run_once: true
- name: Test metallb-system namespace
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }} -n metallb-system
{{ k3s_kubectl_binary | default('k3s kubectl') }} get namespace metallb-system
changed_when: false
# The kube API can briefly return ServiceUnavailable while MetalLB converges,
# which would otherwise abort the whole converge play on a transient error.
register: metallb_namespace_result
until: metallb_namespace_result.rc == 0
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
with_items: "{{ groups[group_name_master | default('master')] }}"
run_once: true
@@ -54,6 +60,12 @@
{% if item.condition | default(False) -%}{{ item.condition }}{%- endif %}
--timeout='{{ metal_lb_available_timeout }}'
changed_when: false
# The kube API can briefly return ServiceUnavailable while MetalLB converges,
# which would otherwise abort the whole converge play on a transient error.
register: metallb_wait_result
until: metallb_wait_result.rc == 0
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
run_once: true
with_items:
- description: controller
@@ -85,22 +97,20 @@
- name: Set metallb webhook service name
ansible.builtin.set_fact:
metallb_webhook_service_name: >-
{{
(
(metal_lb_controller_tag_version | regex_replace('^v', ''))
is
version('0.14.4', '<', version_type='semver')
) | ternary(
'webhook-service',
'metallb-webhook-service'
)
}}
# Inspected the v0.16.0 manifest: the newer webhook Service name is used
# by every supported MetalLB release, so the old pre-0.14.4 branch is gone.
metallb_webhook_service_name: metallb-webhook-service
- name: Test metallb-system webhook-service endpoint
ansible.builtin.command: >-
{{ k3s_kubectl_binary | default('k3s kubectl') }} -n metallb-system get endpoints {{ metallb_webhook_service_name }}
changed_when: false
# The kube API can briefly return ServiceUnavailable while MetalLB converges,
# which would otherwise abort the whole converge play on a transient error.
register: metallb_webhook_result
until: metallb_webhook_result.rc == 0
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
with_items: "{{ groups[group_name_master | default('master')] }}"
run_once: true
@@ -120,6 +130,10 @@
changed_when: false
run_once: true
when: metal_lb_mode == "layer2"
register: metallb_l2_test_result
until: metallb_l2_test_result.rc == 0
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
with_items:
- IPAddressPool
- L2Advertisement
@@ -130,6 +144,10 @@
changed_when: false
run_once: true
when: metal_lb_mode == "bgp"
register: metallb_bgp_test_result
until: metallb_bgp_test_result.rc == 0
retries: "{{ download_retries }}"
delay: "{{ download_delay }}"
with_items:
- IPAddressPool
- BGPPeer
+79 -35
View File
@@ -1,40 +1,84 @@
apiVersion: "cilium.io/v2alpha1"
kind: CiliumBGPPeeringPolicy
# Cilium BGP Control Plane v2 resources.
# Replace the deprecated v2alpha1 CiliumBGPPeeringPolicy that was removed
# in Cilium 1.19.
{% set _cilium_default_peer = {'peer_address': cilium_bgp_peer_address, 'peer_asn': cilium_bgp_peer_asn} %}
{% set _cilium_peers = _cilium_bgp_neighbors if _cilium_bgp_neighbors | length > 0 else [_cilium_default_peer] %}
apiVersion: cilium.io/v2
kind: CiliumBGPPeerConfig
metadata:
name: 01-bgp-peering-policy
spec: # CiliumBGPPeeringPolicySpec
virtualRouters: # []CiliumBGPVirtualRouter
- localASN: {{ cilium_bgp_my_asn }}
exportPodCIDR: {{ cilium_exportPodCIDR | default('true') }}
neighbors: # []CiliumBGPNeighbor
{% if _cilium_bgp_neighbors | length > 0 %}
{% for item in _cilium_bgp_neighbors %}
- peerAddress: '{{ item.peer_address + "/32"}}'
peerASN: {{ item.peer_asn }}
eBGPMultihopTTL: 10
connectRetryTimeSeconds: 120
holdTimeSeconds: 90
keepAliveTimeSeconds: 30
gracefulRestart:
enabled: true
restartTimeSeconds: 120
{% endfor %}
{% else %}
- peerAddress: '{{ cilium_bgp_peer_address + "/32"}}'
peerASN: {{ cilium_bgp_peer_asn }}
eBGPMultihopTTL: 10
connectRetryTimeSeconds: 120
holdTimeSeconds: 90
keepAliveTimeSeconds: 30
gracefulRestart:
enabled: true
restartTimeSeconds: 120
{% endif %}
serviceSelector:
matchExpressions:
- {key: somekey, operator: NotIn, values: ['never-used-value']}
name: cilium-peer
spec:
# Matches the timers and multihop used by the previous v2alpha1 policy.
ebgpMultihop: 10
timers:
connectRetryTimeSeconds: 120
holdTimeSeconds: 90
keepAliveTimeSeconds: 30
gracefulRestart:
enabled: true
restartTimeSeconds: 120
families:
- afi: ipv4
safi: unicast
advertisements:
matchLabels:
advertise: "bgp"
---
apiVersion: "cilium.io/v2alpha1"
apiVersion: cilium.io/v2
kind: CiliumBGPClusterConfig
metadata:
name: cilium-bgp
spec:
# Explicitly select every node so the BGP instance runs across the cluster.
nodeSelector:
matchExpressions:
- key: somekey
operator: NotIn
values: ['never-used-value']
bgpInstances:
- name: "instance-{{ cilium_bgp_my_asn }}"
localASN: {{ cilium_bgp_my_asn }}
peers:
{% for peer in _cilium_peers %}
- name: "peer-{{ peer.peer_asn }}-{{ loop.index }}"
peerASN: {{ peer.peer_asn }}
peerAddress: {{ peer.peer_address }}
peerConfigRef:
name: cilium-peer
{% endfor %}
{% if cilium_exportPodCIDR %}
---
apiVersion: cilium.io/v2
kind: CiliumBGPAdvertisement
metadata:
name: cilium-pod-cidrs
labels:
advertise: "bgp"
spec:
advertisements:
- advertisementType: "PodCIDR"
{% endif %}
---
apiVersion: cilium.io/v2
kind: CiliumBGPAdvertisement
metadata:
name: cilium-lb-services
labels:
advertise: "bgp"
spec:
advertisements:
- advertisementType: "Service"
service:
addresses:
- LoadBalancerIP
# Advertise all Services carrying an ingress address from the pool.
selector:
matchExpressions:
- key: somekey
operator: NotIn
values: ['never-used-value']
---
apiVersion: cilium.io/v2
kind: CiliumLoadBalancerIPPool
metadata:
name: "01-lb-pool"
@@ -21,6 +21,11 @@ kind: L2Advertisement
metadata:
name: default
namespace: metallb-system
{% if metal_lb_interfaces | default([]) | length > 0 %}
spec:
interfaces:{% for iface in metal_lb_interfaces %}
- {{ iface }}{% endfor %}
{% endif %}
{% endif %}
{% if metal_lb_mode == "bgp" %}
---
+2
View File
@@ -1,4 +1,6 @@
---
disable_swap: true
secure_path:
RedHat: /sbin:/bin:/usr/sbin:/usr/bin:/usr/local/bin
Suse: /usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
+57 -6
View File
@@ -4,10 +4,32 @@
name: "{{ system_timezone }}"
when: (system_timezone is defined) and (system_timezone != "Your/Timezone")
# k3s recommends swap be disabled on all nodes. Disabling swap is all-or-nothing
# across the cluster: leaving it enabled on some nodes but not others creates
# uneven scheduling/latency behavior. This block turns swap off and comments out
# the swap entries in /etc/fstab so it stays off across reboots. It is idempotent
# and a no-op when swap is already disabled or swapoff is unavailable.
- name: Disable swap on all cluster nodes
when: disable_swap
block:
- name: Turn off swap now
ansible.builtin.command: swapoff -a
register: swapoff_result
changed_when: false
failed_when: false
- name: Comment out swap entries in fstab
ansible.builtin.replace:
path: /etc/fstab
regexp: '^([^#][^\n]*\s+swap\s+)'
replace: '# \\1'
register: fstab_swap
- name: Set SELinux to disabled state
ansible.posix.selinux:
state: disabled
when: ansible_os_family == "RedHat"
when: ansible_facts.os_family == "RedHat"
- name: Enable IPv4 forwarding
ansible.posix.sysctl:
@@ -33,18 +55,47 @@
reload: true
tags: sysctl
- name: Check if br_netfilter module exists
ansible.builtin.shell: |
set -o pipefail
find /lib/modules/$(uname -r) -name "br_netfilter.ko*" | wc -l
args:
executable: /bin/bash
register: br_netfilter_exists
changed_when: false
when: ansible_facts.os_family == "RedHat"
tags: sysctl
- name: Install kernel-modules-extra if br_netfilter missing
ansible.builtin.yum:
name: "kernel-modules-extra-{{ ansible_kernel }}"
state: present
update_cache: true
register: kernel_modules_installed
when:
- ansible_facts.os_family == "RedHat"
- br_netfilter_exists.stdout | int == 0
- name: Reboot node if kernel modules package was installed
ansible.builtin.reboot:
msg: "Rebooting to apply new kernel modules package"
reboot_timeout: 300
when:
- ansible_facts.os_family == "RedHat"
- kernel_modules_installed is changed
- name: Add br_netfilter to /etc/modules-load.d/
ansible.builtin.copy:
content: br_netfilter
dest: /etc/modules-load.d/br_netfilter.conf
mode: u=rw,g=,o=
when: ansible_os_family == "RedHat"
when: ansible_facts.os_family == "RedHat"
- name: Load br_netfilter
community.general.modprobe:
name: br_netfilter
state: present
when: ansible_os_family == "RedHat"
when: ansible_facts.os_family == "RedHat"
- name: Set bridge-nf-call-iptables (just to be sure)
ansible.posix.sysctl:
@@ -52,7 +103,7 @@
value: "1"
state: present
reload: true
when: ansible_os_family == "RedHat"
when: ansible_facts.os_family == "RedHat"
loop:
- net.bridge.bridge-nf-call-iptables
- net.bridge.bridge-nf-call-ip6tables
@@ -60,10 +111,10 @@
- name: Add /usr/local/bin to sudo secure_path
ansible.builtin.lineinfile:
line: Defaults secure_path = {{ secure_path[ansible_os_family] }}
line: Defaults secure_path = {{ secure_path[ansible_facts.os_family] }}
regexp: Defaults(\s)*secure_path(\s)*=
state: present
insertafter: EOF
path: /etc/sudoers
validate: visudo -cf %s
when: ansible_os_family in [ "RedHat", "Suse" ]
when: ansible_facts.os_family in [ "RedHat", "Suse" ]
+10 -3
View File
@@ -1,20 +1,27 @@
---
# The probes below must run in check mode as well. The command module does not
# execute under --check, it returns rc 0 with "Command would have run if not in
# check mode", which would otherwise mark every host as a Raspberry Pi and make
# check runs apply the distro specific tasks. The rc values mean: 0 matched, 1 no
# match, 2 the file is absent (no /proc/device-tree on non ARM systems).
- name: Test for raspberry pi /proc/cpuinfo
ansible.builtin.command: grep -E "Raspberry Pi|BCM2708|BCM2709|BCM2835|BCM2836" /proc/cpuinfo
register: grep_cpuinfo_raspberrypi
failed_when: false
changed_when: false
check_mode: false
- name: Test for raspberry pi /proc/device-tree/model
ansible.builtin.command: grep -E "Raspberry Pi" /proc/device-tree/model
register: grep_device_tree_model_raspberrypi
failed_when: false
changed_when: false
check_mode: false
- name: Set raspberry_pi fact to true
ansible.builtin.set_fact:
raspberry_pi: true
when: grep_cpuinfo_raspberrypi.rc == 0 or grep_device_tree_model_raspberrypi.rc == 0
when: (grep_cpuinfo_raspberrypi.rc | default(1)) == 0 or (grep_device_tree_model_raspberrypi.rc | default(1)) == 0
- name: Set detected_distribution to Raspbian (ARM64 on Raspbian, Debian Buster/Bullseye/Bookworm)
ansible.builtin.set_fact:
@@ -49,8 +56,8 @@
with_first_found:
- "{{ action_ }}/{{ detected_distribution }}-{{ detected_distribution_major_version }}.yml"
- "{{ action_ }}/{{ detected_distribution }}.yml"
- "{{ action_ }}/{{ ansible_distribution }}-{{ ansible_distribution_major_version }}.yml"
- "{{ action_ }}/{{ ansible_distribution }}.yml"
- "{{ action_ }}/{{ ansible_facts['distribution'] }}-{{ ansible_facts['distribution_major_version'] }}.yml"
- "{{ action_ }}/{{ ansible_facts['distribution'] }}.yml"
- "{{ action_ }}/default.yml"
vars:
action_: >-
+24 -3
View File
@@ -8,6 +8,23 @@
msg: >
"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_facts', 'hostname'])
| list
}}
run_once: true
when: "'k3s_cluster' in groups"
- name: Prepare Proxmox cluster
hosts: proxmox
gather_facts: true
@@ -60,9 +77,13 @@
hosts: master
environment: "{{ proxy_env | default({}) }}"
tasks:
- name: Copying kubeconfig from {{ hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname'] }}
- name: >-
Copying kubeconfig from
{{ hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname'] }}
ansible.builtin.fetch:
src: "{{ ansible_user_dir }}/.kube/config"
src: "{{ ansible_facts['user_dir'] }}/.kube/config"
dest: ./kubeconfig
flat: true
when: ansible_hostname == hostvars[groups[group_name_master | default('master')][0]]['ansible_hostname']
when: >-
ansible_facts.hostname ==
hostvars[groups[group_name_master | default('master')][0]]['ansible_facts']['hostname']