diff --git a/README.md b/README.md index a926694..aa923c0 100644 --- a/README.md +++ b/README.md @@ -160,29 +160,31 @@ cluster: > - **Talos < v1.12** — single YAML document with `machine.network` and `machine.registries` sections (as shown above). > - **Talos >= v1.12** — multi-document format with separate typed documents instead of the deprecated monolithic fields. > -> For v1.12+ multi-doc output, one document is emitted per configurable link on the node, plus a fixed pair on every render: +> For v1.12+ multi-doc output, one document is emitted per configurable link on the node, plus any link declared through `network.extraLinks` and a fixed pair on every render: > > - `HostnameConfig` and `ResolverConfig` — always emitted. > - `LinkConfig` — physical NICs. > - `BondConfig` — bond masters. Bond slaves are filtered out so they do not collide with the master's document. > - `VLANConfig` — VLAN sub-interfaces. > - `BridgeConfig` — bridges, symmetric to `BondConfig` for bonds. Ports discovered via `spec.slaveKind == "bridge"` + `spec.masterIndex`; STP / VLAN-filtering settings reach the output when the bridge controller reports them on `spec.bridgeMaster`. -> - `Layer2VIPConfig` — controlplane nodes when `floatingIP` is set. -> - `RegistryMirrorConfig` — cozystack chart only. +> - `Layer2VIPConfig` — one per VIP. The `floatingIP` shorthand emits it on controlplane nodes; each `vips` entry emits one on any node, so a storage VIP works on a worker. +> - `RegistryMirrorConfig` and `RegistryTLSConfig` — from the `registryMirrors` and `registryTLS` values, available on both charts. > > Per-link emission rules: > > - The link carrying the IPv4 default route gets the `routes.gateway` entry on its document; every other link is emitted gateway-less. Applies uniformly to `LinkConfig`, `BondConfig`, `VLANConfig`, `BridgeConfig`. > - Both IPv4 and IPv6 global-scope addresses on a link are surfaced. -> - The operator-declared `floatingIP` is stripped from per-link addresses so the VIP currently held by a leader does not leak into the static document. +> - Every declared VIP ip (`floatingIP` plus each `vips[].ip`) is stripped from the addresses discovery reports, so a VIP currently held by a leader does not leak into the static document. Addresses are compared canonically, so a VIP spelled differently from what the node reports (`2001:0DB8::5` against `2001:db8::5/64`) is still recognised. An address the operator writes by hand under `network.extraLinks` is refused rather than stripped — the render does not silently drop what it was told to emit. > > Multi-NIC nodes therefore produce one document per NIC, not one document total. +> +> Two values change this shape. `network.extraLinks` declares links that discovery cannot see — a bond, VLAN or address the node does not carry yet — so documents are emitted for links that are not (or not yet) on the node. A link named in `bond.interfaces` becomes a slave and stops getting a document of its own, the same filter discovery applies once the bond exists. Moving an already-addressed NIC into a bond works, provided the entry restates everything the slave was carrying — its `addresses`, a destination-less `routes` entry when it held the default route, and a matching `routes` entry per static route. Anything left behind fails the render rather than silently costing the node its connectivity or a reachable subnet. `network.preserveExisting` goes the other way: the running `machine.network.interfaces` block is carried over verbatim and the typed per-link rebuild is skipped entirely, so no per-link document is emitted at all. VIP and registry documents are unaffected by either. > **Version compatibility (`templateOptions.talosVersion` / `--talos-version`).** This setting must match the **Talos version actually running on the target node** — i.e. the maintenance ISO/PXE the node booted from for `apply -i`, or the installed Talos for an authenticated apply. It is **not** the same as `install.image`, which only controls what gets written to disk after a successful apply. When the configured contract is newer than the running binary, machinery injects fields (e.g. `machine.install.grubUseUKICmdline` from v1.12) that the running parser does not know, and the apply fails on the node side with `failed to parse config: unknown keys found during decoding: ...`. `talm apply` runs a best-effort pre-flight check against the running version and prints a `warning: pre-flight: ...` line with a hint when it detects this mismatch; if the warning is missed, the same hint is appended to the apply error. Either reboot the node into a maintenance image that matches the configured contract, or lower `templateOptions.talosVersion` / `--talos-version` to match what is running. > **Apply-time safety gates.** `talm apply` and `talm upgrade` run additional gates around each operation: > -> 1. **Declared-resource existence** (`--skip-resource-validation` opt-out, default on). Before sending the config to the node, the gate walks the rendered MachineConfig, extracts every reference to a host-side resource (network links from v1.12 multi-doc — `LinkConfig.name`, `BondConfig.links[]`, `VLANConfig.parent`, `BridgeConfig.links[]`, `Layer2VIPConfig.link`, `HCloudVIPConfig.link`, `DHCPv4Config.name` / `DHCPv6Config.name` / `EthernetConfig.name`; v1.11 legacy `machine.network.interfaces[].interface`; install disk via `machine.install.disk` literal or `machine.install.diskSelector`; `UserVolumeConfig.provisioning.diskSelector`), and verifies each against the node's COSI `LinkStatus`/`Disk` snapshots. A reference that doesn't resolve fails the apply with a `[blocker]` line listing the available names so the typo or migration miss is fixable from the values without re-running discovery. Disk selectors must match at least one (non-readonly, non-CDROM, non-virtual) disk — zero matches block, multiple matches warn (install picks the first). Virtual-link-creator documents (`BondConfig.name`, `VLANConfig.name`, `BridgeConfig.name`, `WireguardConfig.name`, `DummyLinkConfig.name`, `LinkAliasConfig.name`) are intentionally NOT validated against existing links — those `.name` fields describe new virtual links the apply is creating, not references to pre-existing host resources. The gate also runs a syntactic net-addr walker against `StaticHostConfig.name` (must parse as an IP literal — the `name` field on this kind doubles as the IP the hostnames map to), `NetworkRuleConfig.ingress[].subnet` and `.except` (per-entry CIDR), and `WireguardConfig.peers[].endpoint` (host:port; empty / absent endpoint is a listener-only peer, NOT a finding). Out of scope today: `machine.disks[].device` (extra-disk partitioning); track in a follow-up if you need it. Pass `--skip-resource-validation` for recovery into a maintenance image with mismatched hardware or pre-staging values for hardware that isn't installed yet. +> 1. **Declared-resource existence** (`--skip-resource-validation` opt-out, default on). Before sending the config to the node, the gate walks the rendered MachineConfig, extracts every reference to a host-side resource (network links from v1.12 multi-doc — `LinkConfig.name`, `BondConfig.links[]`, `VLANConfig.parent`, `BridgeConfig.links[]`, `Layer2VIPConfig.link`, `HCloudVIPConfig.link`, `DHCPv4Config.name` / `DHCPv6Config.name` / `EthernetConfig.name`; v1.11 legacy `machine.network.interfaces[].interface`; install disk via `machine.install.disk` literal or `machine.install.diskSelector`; `UserVolumeConfig.provisioning.diskSelector`), and verifies each against the node's COSI `LinkStatus`/`Disk` snapshots. A reference that doesn't resolve fails the apply with a `[blocker]` line listing the available names so the typo or migration miss is fixable from the values without re-running discovery. Disk selectors must match at least one (non-readonly, non-CDROM, non-virtual) disk — zero matches block, multiple matches warn (install picks the first). Virtual-link-creator documents (`BondConfig.name`, `VLANConfig.name`, `BridgeConfig.name`, `WireguardConfig.name`, `DummyLinkConfig.name`, `LinkAliasConfig.name`) are intentionally NOT validated against existing links — those `.name` fields describe new virtual links the apply is creating, not references to pre-existing host resources. Five of them (`BondConfig`, `VLANConfig`, `BridgeConfig`, `DummyLinkConfig`, `LinkAliasConfig`) additionally register their `.name` as a link this apply brings into existence, so a `VLANConfig.parent`, `BondConfig.links[]` or `Layer2VIPConfig.link` pointing at one of them resolves rather than blocking. That is what makes a `network.extraLinks` bond usable on first apply, before the node carries it. A `LinkAliasConfig` name ending in `%d` is registered as a pattern rather than a literal, because Talos expands it into one sequential alias per matched link (`net0`, `net1`, …) — so a reference to `net0` resolves while the literal `net%d`, which never exists on the node, is not treated as a link. `WireguardConfig` is the exception: it is handled by the net-addr walker (for its `peers[].endpoint`), which stays disjoint from the link walker, so it does not register its name — a VLAN or VIP layered on a wireguard link still blocks on first apply and needs `--skip-resource-validation` until the link exists. The gate also runs a syntactic net-addr walker against `StaticHostConfig.name` (must parse as an IP literal — the `name` field on this kind doubles as the IP the hostnames map to), `NetworkRuleConfig.ingress[].subnet` and `.except` (per-entry CIDR), and `WireguardConfig.peers[].endpoint` (host:port; empty / absent endpoint is a listener-only peer, NOT a finding). Out of scope today: `machine.disks[].device` (extra-disk partitioning); track in a follow-up if you need it. With `network.preserveExisting` the rendered config carries the running `machine.network.interfaces` block instead of typed per-link documents, so the gate validates the legacy `interface` fields from that block. Pass `--skip-resource-validation` for recovery into a maintenance image with mismatched hardware or pre-staging values for hardware that isn't installed yet. > > 2. **Pre-apply drift preview** (`--skip-drift-preview` opt-out, default on). Reads the node's current MachineConfig via COSI and prints a `+`/`-`/`~`/`=` diff of what's about to change, keyed by `(kind, name)`. Informational only — never blocks. The `-` lines are the most useful: they surface stale documents from a previous apply that the new render no longer emits (e.g. an `eth1` LinkConfig lingering after a migration to `eth0`). Reading the current config requires the auth path — `MachineConfig` is a Sensitive COSI resource and is unreachable on the `--insecure` maintenance connection; the gate prints `drift verification unavailable on maintenance connection` (per-node-prefixed on multi-node insecure apply) and proceeds in that case. Secret-bearing field values (`cluster.token`, `cluster.{ca,aggregatorCA,serviceAccount,etcd.ca}.key`, `machine.token` / `machine.ca.key`, the `cluster.acceptedCAs` / `machine.acceptedCAs` slices, `WireguardConfig.privateKey`, the `peers` slice carrying `presharedKey`s) are redacted by default — both sides render as `***redacted (len=N)***` so a rotation surfaces as different-length sentinels without leaking the value. In addition to that static path allowlist, any value originating from an encrypted user value file (`*.encrypted.yaml` referenced via `templateOptions.valueFiles`) is redacted **by value** wherever it surfaces in the diff (at any path, including nested in a slice) — symmetric with how `talm template` redacts the same values. Pass `--show-secrets-in-drift` to see the raw values verbatim (debugging only — disables both the path-based and value-based redaction for the run). **`--dry-run` runs this gate** — the diff is read-only and "show me what would change" is exactly the dry-run contract. > diff --git a/charts/cozystack/templates/_helpers.tpl b/charts/cozystack/templates/_helpers.tpl index 5093556..52d8803 100644 --- a/charts/cozystack/templates/_helpers.tpl +++ b/charts/cozystack/templates/_helpers.tpl @@ -165,6 +165,34 @@ machine: {{- end }} {{- (include "talm.discovered.disks_info" .) | nindent 4 }} disk: {{ include "talm.discovered.system_disk_name" . | quote }} + {{- include "talm.guard.time_servers" . }} + {{- with .Values.timeServers }} + time: + servers: + {{- toYaml . | nindent 6 }} + {{- end }} + {{- /* carry the running node's machine.network.interfaces + verbatim when network.preserveExisting is set, ONLY on the v1.12+ + multi-doc schema (where the typed per-link rebuild is skipped). On + the legacy schema talos.config.network.legacy already emits a + machine.network block natively, so a second one here would be a + duplicate key yaml.v3 rejects — and the legacy renderer already + preserves the running interfaces via its own short-circuit. */}} + {{- $multidoc := and .TalosVersion (not (semverCompare "<1.12.0-0" .TalosVersion)) }} + {{- if and $multidoc .Values.network .Values.network.preserveExisting }} + {{- $existing := include "talm.discovered.existing_interfaces_configuration" . }} + {{- if $existing }} + network: + interfaces: + {{- $existing | nindent 6 }} + {{- end }} + {{- end }} + {{- /* extraLinks renders typed documents only the multi-doc + schema supports. On the legacy schema it would silently no-op, + so fail fast instead of dropping the operator's declared links. */}} + {{- if and (not $multidoc) .Values.network .Values.network.extraLinks }} + {{- fail "talm: network.extraLinks is only supported on the v1.12+ multi-doc schema. Pin templateOptions.talosVersion to v1.12 or later, or declare the extra links via a per-node body overlay for legacy renders." }} + {{- end }} {{- end }} {{- /* Shared cluster section */ -}} @@ -192,16 +220,40 @@ cluster: {{- else }} allocate-node-cidrs: false {{- end }} + {{- range $k, $_ := .Values.extraControllerManagerArgs }} + {{- /* cluster-cidr is preset-owned only when allocateNodeCIDRs is on + (that is the only branch that emits it); with it off the + operator may set cluster-cidr freely. */}} + {{- if or (eq $k "bind-address") (eq $k "allocate-node-cidrs") (and (eq $k "cluster-cidr") $.Values.allocateNodeCIDRs) }} + {{- fail (printf "values.yaml: extraControllerManagerArgs.%s collides with the cozystack preset's built-in controllerManager.extraArgs; drop it from extraControllerManagerArgs" $k) }} + {{- end }} + {{- end }} + {{- include "talm.render.component_args" (dict "args" .Values.extraControllerManagerArgs "field" "extraControllerManagerArgs") }} scheduler: extraArgs: bind-address: 0.0.0.0 + {{- range $k, $_ := .Values.extraSchedulerArgs }} + {{- if eq $k "bind-address" }} + {{- fail (printf "values.yaml: extraSchedulerArgs.%s collides with the cozystack preset's built-in scheduler.extraArgs; drop it from extraSchedulerArgs" $k) }} + {{- end }} + {{- end }} + {{- include "talm.render.component_args" (dict "args" .Values.extraSchedulerArgs "field" "extraSchedulerArgs") }} apiServer: - {{- if and .Values.oidcIssuerUrl (ne .Values.oidcIssuerUrl "") }} + {{- $oidcSet := and .Values.oidcIssuerUrl (ne .Values.oidcIssuerUrl "") }} + {{- if or $oidcSet .Values.extraApiServerArgs }} extraArgs: + {{- if $oidcSet }} oidc-issuer-url: "{{ .Values.oidcIssuerUrl }}" oidc-client-id: "kubernetes" oidc-username-claim: "preferred_username" oidc-groups-claim: "groups" + {{- end }} + {{- range $k, $_ := .Values.extraApiServerArgs }} + {{- if and $oidcSet (has $k (list "oidc-issuer-url" "oidc-client-id" "oidc-username-claim" "oidc-groups-claim")) }} + {{- fail (printf "values.yaml: extraApiServerArgs.%s collides with the cozystack preset's built-in apiServer OIDC args (active because oidcIssuerUrl is set); drop it from extraApiServerArgs" $k) }} + {{- end }} + {{- end }} + {{- include "talm.render.component_args" (dict "args" .Values.extraApiServerArgs "field" "extraApiServerArgs") }} {{- end }} certSANs: - 127.0.0.1 @@ -268,6 +320,7 @@ cluster: nameservers: {{ include "talm.discovered.default_resolvers" . }} {{- (include "talm.discovered.physical_links_info" .) | nindent 4 }} {{- $existingInterfacesConfiguration := include "talm.discovered.existing_interfaces_configuration" . }} + {{- $existingLinkNames := fromJsonArray (include "talm.discovered.existing_interface_names" .) }} {{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} {{- /* vipLink override on the legacy schema: legacy Talos has no Layer2VIPConfig document, so the override is expressed as a @@ -279,7 +332,7 @@ cluster: has redirected it to a different link; otherwise the VIP would be pinned twice on different interfaces. */}} {{- $suppressInlineVip := and .Values.vipLink (ne .Values.vipLink $defaultLinkName) }} - {{- if or $existingInterfacesConfiguration $defaultLinkName $vipOverride }} + {{- if or $existingInterfacesConfiguration $defaultLinkName $vipOverride .Values.vips }} interfaces: {{- if $existingInterfacesConfiguration }} {{- $existingInterfacesConfiguration | nindent 4 }} @@ -321,20 +374,78 @@ cluster: {{- end }} {{- end }} {{- if $vipOverride }} + {{- if has (.Values.vipLink | toString) $existingLinkNames }} + {{- /* The preserved interfaces block already emits this link verbatim; + a second entry for the vipLink override would be a duplicate + device Talos won't merge. */}} + {{- fail (printf "talm: vipLink %q is already present in the node's running machine.network.interfaces (emitted verbatim on the legacy schema); the VIP override would produce a duplicate device Talos won't merge. Add the vip inline to that interface via a per-node body overlay, or pin templateOptions.talosVersion to v1.12+ where VIPs are separate Layer2VIPConfig documents." .Values.vipLink) }} + {{- end }} - interface: {{ .Values.vipLink }} vip: ip: {{ $fipStr }} {{- end }} + {{- /* one interface entry with an inline vip per vips entry + (legacy schema has no Layer2VIPConfig document). Seed the seen + link/ip sets with the vipLink override and floatingIP so a vips + entry that collides with either is caught, matching the multidoc + path's dedup. */}} + {{- $seenVipLinks := list }} + {{- if $vipOverride }} + {{- $seenVipLinks = append $seenVipLinks (.Values.vipLink | toString) }} + {{- end }} + {{- $seenVipIPs := list }} + {{- if $fipIsSet }} + {{- $seenVipIPs = append $seenVipIPs (ipCanonical $fipStr) }} + {{- end }} + {{- range .Values.vips }} + {{- if not (ipIsValid (.ip | toString)) }} + {{- fail (printf "talm: vips[].ip %q is not a valid IPv4 / IPv6 literal. Edit values.yaml and re-run." .ip) }} + {{- end }} + {{- if not .link }} + {{- fail (printf "talm: a vips entry (ip %q) has no link. Each vips entry must name the link the VIP is pinned to." (.ip | toString)) }} + {{- end }} + {{- /* Only meaningful when the rebuild actually emitted the primary + link. When the preserved block was used instead, the primary has + no rebuild-generated entry and the preserved-name check below is + the one that applies (with the accurate message). */}} + {{- if and (not $existingInterfacesConfiguration) (eq (.link | toString) $defaultLinkName) }} + {{- /* On legacy the primary link already has its own interfaces[] + entry carrying addresses/routes; a second entry for the same + link would be a duplicate device Talos does not merge. Refuse + it and point at the right tool for a primary-link VIP. */}} + {{- fail (printf "talm: vips entry link %q is the discovered primary link, which already has an interfaces[] entry on the legacy schema; a second entry for it produces a duplicate device Talos won't merge. Use floatingIP for a VIP on the primary link, or pin templateOptions.talosVersion to v1.12+ where VIPs are separate Layer2VIPConfig documents." .link) }} + {{- end }} + {{- if has (.link | toString) $existingLinkNames }} + {{- /* The preserved interfaces block (a re-apply of a legacy-applied + node, or preserveExisting) already emits this link verbatim, so + a vips entry naming it would double-declare the device. Fail + fast the same way the primary-link collision does. */}} + {{- fail (printf "talm: vips entry link %q is already present in the node's running machine.network.interfaces (emitted verbatim on the legacy schema); a second entry for it produces a duplicate device Talos won't merge. Add the vip inline to that interface via a per-node body overlay, or pin templateOptions.talosVersion to v1.12+ where VIPs are separate Layer2VIPConfig documents." .link) }} + {{- end }} + {{- if has (.link | toString) $seenVipLinks }} + {{- /* The legacy interfaces[].vip holds a single IP per interface, so + two VIPs on one link (including the vipLink override) cannot both + be expressed. Fail fast rather than emit a duplicate device. */}} + {{- fail (printf "talm: link %q already carries a VIP (via vipLink or another vips entry) on the legacy schema, which holds at most one inline vip per interface. Pin templateOptions.talosVersion to v1.12+ where each VIP is a separate Layer2VIPConfig document." .link) }} + {{- end }} + {{- if has (ipCanonical (.ip | toString)) $seenVipIPs }} + {{- /* The same VIP ip pinned to two links loses arbitration on apply + (both interfaces claim it). Fail fast, matching the multidoc + path's ip-uniqueness check. */}} + {{- fail (printf "talm: VIP ip %q is declared more than once (across floatingIP and vips) on the legacy schema. Each VIP ip must be unique." (.ip | toString)) }} + {{- end }} + {{- $seenVipLinks = append $seenVipLinks (.link | toString) }} + {{- $seenVipIPs = append $seenVipIPs (ipCanonical (.ip | toString)) }} + - interface: {{ .link }} + vip: + ip: {{ .ip }} + {{- end }} {{- end }} {{- end }} {{- define "talos.config.legacy" }} {{- include "talos.config.machine.common" . }} - registries: - mirrors: - docker.io: - endpoints: - - https://mirror.gcr.io +{{- include "talm.config.registries.legacy" . }} {{- include "talos.config.network.legacy" . }} {{- include "talos.config.cluster" . }} @@ -344,11 +455,6 @@ cluster: {{- include "talos.config.machine.common" . }} {{- include "talos.config.cluster" . }} ---- -apiVersion: v1alpha1 -kind: RegistryMirrorConfig -name: docker.io -endpoints: - - url: https://mirror.gcr.io +{{- include "talm.config.registries.multidoc" . }} {{- include "talos.config.network.multidoc" . }} {{- end }} diff --git a/charts/cozystack/values.yaml b/charts/cozystack/values.yaml index e69f3bc..35b58da 100644 --- a/charts/cozystack/values.yaml +++ b/charts/cozystack/values.yaml @@ -192,3 +192,138 @@ extraSysctls: {} # [debug] # level = "info" extraMachineFiles: [] + +# Upstream NTP servers, rendered as machine.time.servers when non-empty. +# Empty (the default) leaves Talos on its built-in time source. Set this +# for nodes on an isolated network that must point at a local time +# source, e.g. the gateway. Example: +# timeServers: [192.0.2.1] +timeServers: [] + +# Extra control-plane component args, appended to each component's +# extraArgs. Same convention as extraKubeletExtraArgs: keys append, and +# a key that collides with a preset built-in fails the render — for +# controllerManager (bind-address / allocate-node-cidrs / cluster-cidr), +# scheduler (bind-address), and apiServer (the oidc-* args, but only +# when oidcIssuerUrl is set). Example: +# extraApiServerArgs: { max-requests-inflight: "2000" } +extraApiServerArgs: {} +extraControllerManagerArgs: {} +extraSchedulerArgs: {} + +# Container registry mirrors, driving both the legacy +# machine.registries.mirrors block and the multidoc RegistryMirrorConfig +# documents. Each key is a registry host mapped to a list of mirror +# endpoints. The default mirrors docker.io through gcr; add more hosts +# (e.g. ghcr.io) or point at a private pull-through cache. To emit NO +# registry config at all, null the key out (`registryMirrors:` with no +# value) — a per-node override merges deeply, so `{}` keeps the default +# docker.io mirror rather than clearing it. Example: +# registryMirrors: +# docker.io: { endpoints: [https://registry.example.com/v2/docker.io] } +# ghcr.io: { endpoints: [https://registry.example.com/v2/ghcr.io] } +registryMirrors: + docker.io: + endpoints: + - https://mirror.gcr.io + +# TLS behaviour per registry ENDPOINT host, driving the legacy +# machine.registries.config..tls block and the multidoc +# RegistryTLSConfig documents. The key is the hostname of the endpoint URL +# (e.g. `registry.example.com` for `https://registry.example.com/v2/`), not +# the mirror name — so a self-signed pull-through cache can be trusted +# without touching registryMirrors. +# +# Each entry must say something: set `ca` to trust a private CA, and/or +# `insecureSkipVerify` to decide whether server verification stays on. An +# entry with neither fails the render rather than silently picking a +# security posture. Prefer `ca` — it keeps verification on. +# +# `ca` is given as PEM, the form you already have on disk; the chart +# adapts it per schema (the legacy field base64-decodes on load, the +# multi-doc document takes PEM verbatim). Example: +# registryTLS: +# registry.example.com: +# ca: | +# -----BEGIN CERTIFICATE----- +# ... +# -----END CERTIFICATE----- +# legacy-cache.example.com: { insecureSkipVerify: true } +registryTLS: {} + +# Additional Layer2 VIPs, each pinned to a link — use this for more than +# one VIP, e.g. a control-plane VIP on the primary link and a separate +# VIP on a storage link. floatingIP + vipLink remain the single-VIP +# shorthand and may be used alongside vips. Each vips[].ip is validated +# as an IP literal. On the pre-1.12 legacy schema a VIP is an inline +# machine.network.interfaces[].vip, so a vips link must not be one already +# emitted verbatim from the running interfaces block (the primary link, or +# any link on a re-applied legacy node) — the render fails fast if it is. +# On v1.12+ each VIP is a separate Layer2VIPConfig with no such limit. +# Example: +# vips: +# - { link: bond0, ip: 192.0.2.254 } +# - { link: bond1, ip: 203.0.113.254 } +vips: [] + +# Network topology beyond the discovered primary link. +network: + # preserveExisting: emit the running node's machine.network.interfaces + # verbatim instead of rebuilding only the discovered primary link, and + # skip the typed per-link rebuild. Use when a node's applied topology + # is richer than the preset reconstructs (secondary links not on the + # default route, extra VLANs, multiple addresses on one link). + # + # This flag only does something on the v1.12+ multi-doc schema. The + # legacy renderer already emits the running interfaces verbatim whenever + # they are present, so setting it there changes nothing. + preserveExisting: false + # extraLinks: declarative additional links layered on the discovered + # topology — bonds, VLANs, and extra addresses discovery does not + # reconstruct. An entry with a bond or addresses declares a new link; + # an entry with only vlans parents those VLANs onto its interface, which + # must already exist — a discovered link or a link/bond declared in + # another extraLinks entry. + # + # An entry that declares a link (addresses or bond) and each vlans[] + # child accept mtu and routes; setting them on an entry that declares no + # link fails the render, since there is nothing to attach them to. A + # route needs a gateway; omit destination for a default route. An address + # that is also a declared VIP is refused — the VIP is installed by its + # Layer2VIPConfig. Addresses are CIDR, and a vlanId must be 1-4094. + # + # A bond REQUIRES `mode` (Talos rejects a BondConfig without it). The + # remaining bond keys are optional tuning, matching what the discovery + # path reconstructs: xmitHashPolicy, lacpRate, miimon, updelay, + # downdelay. + # + # A link named in bond.interfaces becomes a slave and gets no document + # of its own — the same filter discovery applies once the bond exists. + # Moving an already-addressed NIC into a bond works, but the entry has + # to say where its addressing goes: a slave carrying addresses needs + # `addresses` on the bond entry, one carrying the default route needs a + # destination-less `routes` entry, and each static route it carries + # needs a `routes` entry with a matching destination. Otherwise the + # render fails rather than dropping the node's connectivity on the + # floor, or a subnet off its routing table. + # Example: + # extraLinks: + # - interface: bond1 + # mtu: 1400 + # bond: + # interfaces: [enp3s0, enp4s0] + # mode: 802.3ad + # xmitHashPolicy: layer2+3 + # lacpRate: slow + # miimon: 100 + # addresses: [203.0.113.10/24] + # routes: + # - gateway: 203.0.113.1 + # - interface: bond1 # VLAN on the bond declared above + # vlans: + # - vlanId: 7 + # addresses: [198.51.100.10/24] + # mtu: 1400 + # routes: + # - gateway: 198.51.100.1 + extraLinks: [] diff --git a/charts/generic/templates/_helpers.tpl b/charts/generic/templates/_helpers.tpl index 9bfb11f..e63553a 100644 --- a/charts/generic/templates/_helpers.tpl +++ b/charts/generic/templates/_helpers.tpl @@ -58,6 +58,31 @@ machine: install: {{- (include "talm.discovered.disks_info" .) | nindent 4 }} disk: {{ include "talm.discovered.system_disk_name" . | quote }} + {{- include "talm.guard.time_servers" . }} + {{- with .Values.timeServers }} + time: + servers: + {{- toYaml . | nindent 6 }} + {{- end }} + {{- /* carry the running node's machine.network.interfaces + verbatim when network.preserveExisting is set, ONLY on the v1.12+ + multi-doc schema. On legacy, talos.config.network.legacy already + emits machine.network natively, so a second one here would be a + duplicate key yaml.v3 rejects. */}} + {{- $multidoc := and .TalosVersion (not (semverCompare "<1.12.0-0" .TalosVersion)) }} + {{- if and $multidoc .Values.network .Values.network.preserveExisting }} + {{- $existing := include "talm.discovered.existing_interfaces_configuration" . }} + {{- if $existing }} + network: + interfaces: + {{- $existing | nindent 6 }} + {{- end }} + {{- end }} + {{- /* extraLinks is multi-doc only; fail fast on legacy rather + than silently dropping the operator's declared links. */}} + {{- if and (not $multidoc) .Values.network .Values.network.extraLinks }} + {{- fail "talm: network.extraLinks is only supported on the v1.12+ multi-doc schema. Pin templateOptions.talosVersion to v1.12 or later, or declare the extra links via a per-node body overlay for legacy renders." }} + {{- end }} {{- end }} {{- /* Shared cluster section */ -}} @@ -73,7 +98,26 @@ cluster: controlPlane: endpoint: {{ required "values.yaml: `endpoint` must be set to the cluster control-plane URL (e.g. https://:6443). This field is cluster-wide: every node's kubelet and kube-proxy dials it, so it cannot be auto-derived from the current node's IP -- `talm template` runs once per node and has no way to reconcile per-node IPs into a single shared endpoint. For multi-node setups use a VIP or an external load balancer; for single-node clusters the node's routable IP works." .Values.endpoint | quote }} {{- if eq .MachineType "controlplane" }} + {{- with .Values.extraControllerManagerArgs }} + controllerManager: + extraArgs: + {{- include "talm.render.component_args" (dict "args" . "field" "extraControllerManagerArgs") }} + {{- end }} + {{- with .Values.extraSchedulerArgs }} + scheduler: + extraArgs: + {{- include "talm.render.component_args" (dict "args" . "field" "extraSchedulerArgs") }} + {{- end }} + {{- /* apiServer is emitted unconditionally, even with both children + unset: it is the container an operator adds certSANs or + extraArgs to, and TestContract_Cluster_GenericApiServerBlock + ExistsButEmpty pins that. A bare `apiServer:` is a null value + Talos accepts. */}} apiServer: + {{- with .Values.extraApiServerArgs }} + extraArgs: + {{- include "talm.render.component_args" (dict "args" . "field" "extraApiServerArgs") }} + {{- end }} {{- with .Values.certSANs }} certSANs: {{- toYaml . | nindent 4 }} @@ -118,6 +162,7 @@ cluster: nameservers: {{ include "talm.discovered.default_resolvers" . }} {{- (include "talm.discovered.physical_links_info" .) | nindent 4 }} {{- $existingInterfacesConfiguration := include "talm.discovered.existing_interfaces_configuration" . }} + {{- $existingLinkNames := fromJsonArray (include "talm.discovered.existing_interface_names" .) }} {{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} {{- /* vipLink override on the legacy schema: legacy Talos has no Layer2VIPConfig document, so the override is expressed as a @@ -129,7 +174,7 @@ cluster: has redirected it to a different link; otherwise the VIP would be pinned twice on different interfaces. */}} {{- $suppressInlineVip := and .Values.vipLink (ne .Values.vipLink $defaultLinkName) }} - {{- if or $existingInterfacesConfiguration $defaultLinkName $vipOverride }} + {{- if or $existingInterfacesConfiguration $defaultLinkName $vipOverride .Values.vips }} interfaces: {{- if $existingInterfacesConfiguration }} {{- $existingInterfacesConfiguration | nindent 4 }} @@ -171,15 +216,73 @@ cluster: {{- end }} {{- end }} {{- if $vipOverride }} + {{- if has (.Values.vipLink | toString) $existingLinkNames }} + {{- /* The preserved interfaces block already emits this link verbatim; + a second entry for the vipLink override would be a duplicate + device Talos won't merge. */}} + {{- fail (printf "talm: vipLink %q is already present in the node's running machine.network.interfaces (emitted verbatim on the legacy schema); the VIP override would produce a duplicate device Talos won't merge. Add the vip inline to that interface via a per-node body overlay, or pin templateOptions.talosVersion to v1.12+ where VIPs are separate Layer2VIPConfig documents." .Values.vipLink) }} + {{- end }} - interface: {{ .Values.vipLink }} vip: ip: {{ $fipStr }} {{- end }} + {{- /* one interface entry with an inline vip per vips entry + (legacy schema has no Layer2VIPConfig document). Seed the seen + link/ip sets with the vipLink override and floatingIP so a vips + entry colliding with either is caught, matching multidoc. */}} + {{- $seenVipLinks := list }} + {{- if $vipOverride }} + {{- $seenVipLinks = append $seenVipLinks (.Values.vipLink | toString) }} + {{- end }} + {{- $seenVipIPs := list }} + {{- if $fipIsSet }} + {{- $seenVipIPs = append $seenVipIPs (ipCanonical $fipStr) }} + {{- end }} + {{- range .Values.vips }} + {{- if not (ipIsValid (.ip | toString)) }} + {{- fail (printf "talm: vips[].ip %q is not a valid IPv4 / IPv6 literal. Edit values.yaml and re-run." .ip) }} + {{- end }} + {{- if not .link }} + {{- fail (printf "talm: a vips entry (ip %q) has no link. Each vips entry must name the link the VIP is pinned to." (.ip | toString)) }} + {{- end }} + {{- /* Only meaningful when the rebuild actually emitted the primary + link. When the preserved block was used instead, the primary has + no rebuild-generated entry and the preserved-name check below is + the one that applies (with the accurate message). */}} + {{- if and (not $existingInterfacesConfiguration) (eq (.link | toString) $defaultLinkName) }} + {{- /* The primary link already has its own interfaces[] entry; a + second one for the same link is a duplicate device Talos does + not merge. Refuse it and point at the right tool. */}} + {{- fail (printf "talm: vips entry link %q is the discovered primary link, which already has an interfaces[] entry on the legacy schema; a second entry for it produces a duplicate device Talos won't merge. Use floatingIP for a VIP on the primary link, or pin templateOptions.talosVersion to v1.12+ where VIPs are separate Layer2VIPConfig documents." .link) }} + {{- end }} + {{- if has (.link | toString) $existingLinkNames }} + {{- /* The preserved interfaces block (a re-apply of a legacy-applied + node, or preserveExisting) already emits this link verbatim, so + a vips entry naming it would double-declare the device. */}} + {{- fail (printf "talm: vips entry link %q is already present in the node's running machine.network.interfaces (emitted verbatim on the legacy schema); a second entry for it produces a duplicate device Talos won't merge. Add the vip inline to that interface via a per-node body overlay, or pin templateOptions.talosVersion to v1.12+ where VIPs are separate Layer2VIPConfig documents." .link) }} + {{- end }} + {{- if has (.link | toString) $seenVipLinks }} + {{- /* One inline vip per interface on legacy; two VIPs on one link + (including the vipLink override) cannot both be expressed. */}} + {{- fail (printf "talm: link %q already carries a VIP (via vipLink or another vips entry) on the legacy schema, which holds at most one inline vip per interface. Pin templateOptions.talosVersion to v1.12+ where each VIP is a separate Layer2VIPConfig document." .link) }} + {{- end }} + {{- if has (ipCanonical (.ip | toString)) $seenVipIPs }} + {{- /* The same VIP ip on two links loses arbitration on apply; fail + fast, matching the multidoc ip-uniqueness check. */}} + {{- fail (printf "talm: VIP ip %q is declared more than once (across floatingIP and vips) on the legacy schema. Each VIP ip must be unique." (.ip | toString)) }} + {{- end }} + {{- $seenVipLinks = append $seenVipLinks (.link | toString) }} + {{- $seenVipIPs = append $seenVipIPs (ipCanonical (.ip | toString)) }} + - interface: {{ .link }} + vip: + ip: {{ .ip }} + {{- end }} {{- end }} {{- end }} {{- define "talos.config.legacy" }} {{- include "talos.config.machine.common" . }} +{{- include "talm.config.registries.legacy" . }} {{- include "talos.config.network.legacy" . }} {{- include "talos.config.cluster" . }} @@ -189,5 +292,6 @@ cluster: {{- include "talos.config.machine.common" . }} {{- include "talos.config.cluster" . }} +{{- include "talm.config.registries.multidoc" . }} {{- include "talos.config.network.multidoc" . }} {{- end }} diff --git a/charts/generic/values.yaml b/charts/generic/values.yaml index a8f05dd..12bd405 100644 --- a/charts/generic/values.yaml +++ b/charts/generic/values.yaml @@ -124,3 +124,103 @@ extraSysctls: {} # content: | # hello = "world" extraMachineFiles: [] + +# Upstream NTP servers, rendered as machine.time.servers when non-empty. +# Empty (the default) leaves Talos on its built-in time source. Set this +# for nodes on an isolated network that must point at a local time +# source. Example: +# timeServers: [192.0.2.1] +timeServers: [] + +# Extra control-plane component args (parity with the cozystack preset +# so a values file is portable between presets). Plain passthrough — +# generic has no built-in component args to collide with. Example: +# extraApiServerArgs: { max-requests-inflight: "2000" } +extraApiServerArgs: {} +extraControllerManagerArgs: {} +extraSchedulerArgs: {} + +# Container registry mirrors, keyed by registry host. The generic preset +# ships no default (cozystack mirrors docker.io through gcr); this key +# exists so a values file stays portable between the two presets. Example: +# registryMirrors: +# docker.io: { endpoints: [https://registry.example.com/v2/docker.io] } +registryMirrors: {} + +# TLS posture per registry ENDPOINT host — the hostname of the endpoint +# URL, not the mirror name. Each entry must set `ca` (PEM) and/or +# `insecureSkipVerify`; an entry with neither fails the render. Example: +# registryTLS: +# registry.example.com: { insecureSkipVerify: true } +registryTLS: {} + +# Additional Layer2 VIPs, each pinned to a link. floatingIP + vipLink +# remain the single-VIP shorthand and may be used alongside vips. Each +# vips[].ip is validated as an IP literal. On the pre-1.12 legacy schema a +# VIP is an inline machine.network.interfaces[].vip, so a vips link must +# not be one already emitted verbatim from the running interfaces block +# (the primary link, or any link on a re-applied legacy node) — the render +# fails fast if it is. On v1.12+ each VIP is a separate Layer2VIPConfig +# with no such limit. Example: +# vips: +# - { link: bond0, ip: 192.0.2.254 } +# - { link: bond1, ip: 203.0.113.254 } +vips: [] + +# Network topology beyond the discovered primary link. +network: + # preserveExisting: emit the running node's machine.network.interfaces + # verbatim instead of rebuilding only the discovered primary link, and + # skip the typed per-link rebuild. + # + # Multi-doc schema only: the legacy renderer already emits the running + # interfaces verbatim whenever they are present. + preserveExisting: false + # extraLinks: declarative additional links layered on the discovered + # topology — bonds, VLANs, and extra addresses. An entry with a bond or + # addresses declares a new link; an entry with only vlans parents those + # VLANs onto its interface, which must already exist — a discovered link + # or a link/bond declared in another extraLinks entry. + # + # An entry that declares a link (addresses or bond) and each vlans[] + # child accept mtu and routes; setting them on an entry that declares no + # link fails the render, since there is nothing to attach them to. A + # route needs a gateway; omit destination for a default route. An address + # that is also a declared VIP is refused — the VIP is installed by its + # Layer2VIPConfig. Addresses are CIDR, and a vlanId must be 1-4094. + # + # A bond REQUIRES `mode` (Talos rejects a BondConfig without it). The + # remaining bond keys are optional tuning, matching what the discovery + # path reconstructs: xmitHashPolicy, lacpRate, miimon, updelay, + # downdelay. + # + # A link named in bond.interfaces becomes a slave and gets no document + # of its own — the same filter discovery applies once the bond exists. + # Moving an already-addressed NIC into a bond works, but the entry has + # to say where its addressing goes: a slave carrying addresses needs + # `addresses` on the bond entry, one carrying the default route needs a + # destination-less `routes` entry, and each static route it carries + # needs a `routes` entry with a matching destination. Otherwise the + # render fails rather than dropping the node's connectivity on the + # floor, or a subnet off its routing table. + # Example: + # extraLinks: + # - interface: bond1 + # mtu: 1400 + # bond: + # interfaces: [enp3s0, enp4s0] + # mode: 802.3ad + # xmitHashPolicy: layer2+3 + # lacpRate: slow + # miimon: 100 + # addresses: [203.0.113.10/24] + # routes: + # - gateway: 203.0.113.1 + # - interface: bond1 # VLAN on the bond declared above + # vlans: + # - vlanId: 7 + # addresses: [198.51.100.10/24] + # mtu: 1400 + # routes: + # - gateway: 198.51.100.1 + extraLinks: [] diff --git a/charts/talm/templates/_helpers.tpl b/charts/talm/templates/_helpers.tpl index fa42015..59f0122 100644 --- a/charts/talm/templates/_helpers.tpl +++ b/charts/talm/templates/_helpers.tpl @@ -133,14 +133,20 @@ {{- break }} {{- end }} {{- end }} -{{- /* Coerce .Values.floatingIP through toString before the prefix - compare so an unquoted numeric YAML scalar (operator writes - `floatingIP: 192168`) does not emit `%!s(int=192168)/` that - never matches a real CIDR. Same trap the v1.12 multi-doc - path guards against; legacy schema needs the same treatment. - toString'd nil renders as "" which is also harmless — - it cannot match a real CIDR prefix. */ -}} +{{- /* Coerce .Values.floatingIP through toString before comparing so an + unquoted numeric YAML scalar (operator writes `floatingIP: 192168`) + is handled as text rather than crashing the formatter. Same trap the + v1.12 multi-doc path guards against; legacy schema needs the same + treatment. toString'd nil renders as "", which is harmless — it + canonicalises to itself and cannot equal a real address. + + Canonicalise once here: the strip below compares the host part of + each discovered CIDR against this value, and one IPv6 address has + several spellings. Without it a floatingIP written as 2001:0DB8::5 + fails to match the discovered 2001:db8::5/64 and the VIP ends up + pinned as a static address as well. */ -}} {{- $fipStr := $.Values.floatingIP | toString }} +{{- $fipCanon := ipCanonical $fipStr }} {{- $addresses := list }} {{- /* Drop the same kernel-managed scopes addresses_by_link rejects (host loopback, link-local, "nowhere"). Real Talos @@ -161,7 +167,7 @@ {{- $address := .spec.address | toString }} {{- $validCidr := ge (cidrPrefixLen $address) 0 }} {{- if and (eq .spec.linkName $linkName) (eq .spec.family $family) (not (has (.spec.scope | toString) $skipScopes)) $validCidr }} -{{- if not (hasPrefix (printf "%s/" $fipStr) $address) }} +{{- if ne (ipCanonical ((splitList "/" $address) | first)) $fipCanon }} {{- $addresses = append $addresses $address }} {{- end }} {{- end }} @@ -270,19 +276,295 @@ busPath: {{ .spec.busPath }} {{- end }} {{- end }} -{{- define "talm.discovered.existing_interfaces_configuration" }} +{{- /* The running MachineConfig's machine.network.interfaces[] as JSON. + Single source of truth for the two readers below, which would + otherwise carry duplicate copies of this spec-shape handling (the + spec arrives either as a YAML string or as an already-decoded map, + depending on how the resource was fetched). */ -}} +{{- define "talm.discovered.existing_interfaces_raw" }} +{{- $interfaces := list }} {{- with (lookup "machineconfig" "" "v1alpha1") }} {{- $spec := .spec }} -{{- $interfaces := list }} {{- if kindIs "string" $spec }} {{- $interfaces = $spec | fromYaml | dig "machine" "network" "interfaces" (list) }} {{- else }} {{- $interfaces = $spec | dig "machine" "network" "interfaces" (list) }} {{- end }} +{{- end }} +{{- toJson $interfaces }} +{{- end }} + +{{- define "talm.discovered.existing_interfaces_configuration" }} +{{- $interfaces := fromJsonArray (include "talm.discovered.existing_interfaces_raw" .) }} {{- if $interfaces }} {{- $interfaces | toYaml }} {{- end }} {{- end }} + +{{- /* Shared tail for a declaratively-emitted link document: the optional + routes list and mtu, which CommonLinkConfig accepts identically on + LinkConfig, BondConfig and VLANConfig. Kept in one place so the + three emission sites cannot drift. Expects a dict with "routes", + "mtu" and "name" (the latter only for error messages). A route + needs a gateway; destination is optional and absent means a default + route, matching what the discovery path emits. */ -}} +{{- /* Shared registry knobs, used by every preset so a values file stays + portable between them. registryMirrors is keyed by registry host and + maps to endpoint URLs; registryTLS is keyed by the ENDPOINT host and + carries the TLS posture for it. Guards here rather than per preset, + so the two copies cannot drift. */ -}} +{{- define "talm.guard.registries" }} +{{- range $name, $cfg := .Values.registryMirrors }} +{{- if not (kindIs "map" $cfg) }} +{{- fail (printf "talm: registryMirrors.%s must be a mapping with an endpoints list (got %s). Example: %q: { endpoints: [https://mirror.example.com] }." $name (kindOf $cfg) $name) }} +{{- end }} +{{- if not $cfg.endpoints }} +{{- fail (printf "talm: registryMirrors.%s has no endpoints. Each mirror needs at least one endpoint URL." $name) }} +{{- end }} +{{- if not (kindIs "slice" $cfg.endpoints) }} +{{- fail (printf "talm: registryMirrors.%s endpoints must be a list (got %s) — a bare URL is the missing-dash typo. Example: %q: { endpoints: [https://mirror.example.com] }." $name (kindOf $cfg.endpoints) $name) }} +{{- end }} +{{- range $cfg.endpoints }} +{{- if not (or (hasPrefix "http://" (. | toString)) (hasPrefix "https://" (. | toString))) }} +{{- fail (printf "talm: registryMirrors.%s endpoint %q has no scheme. Talos rejects it with \"unsupported scheme\"; write the full URL, e.g. https://%s." $name (. | toString) (. | toString)) }} +{{- end }} +{{- end }} +{{- end }} +{{- range $host, $cfg := .Values.registryTLS }} +{{- if eq ($host | toString) "*" }} +{{- fail "talm: registryTLS cannot use the \"*\" fallback key. Talos accepts it for registryMirrors only; TLS must be pinned to a concrete registry endpoint host." }} +{{- end }} +{{- if not (kindIs "map" $cfg) }} +{{- fail (printf "talm: registryTLS.%s must be a mapping (got %s). Example: %q: { insecureSkipVerify: true }." $host (kindOf $cfg) $host) }} +{{- end }} +{{- if and (kindIs "invalid" $cfg.insecureSkipVerify) (not $cfg.ca) }} +{{- fail (printf "talm: registryTLS.%s says nothing. Set ca to trust a private CA, and/or insecureSkipVerify to decide whether server verification stays on." $host) }} +{{- end }} +{{- if and (not (kindIs "invalid" $cfg.insecureSkipVerify)) (not (kindIs "bool" $cfg.insecureSkipVerify)) }} +{{- fail (printf "talm: registryTLS.%s insecureSkipVerify must be true or false (got %s). This field decides whether server verification stays on, so an ambiguous YAML scalar is refused." $host (kindOf $cfg.insecureSkipVerify)) }} +{{- end }} +{{- if and $cfg.ca (not (kindIs "string" $cfg.ca)) }} +{{- fail (printf "talm: registryTLS.%s ca must be a PEM-encoded string (got %s). Paste the certificate as a YAML block scalar." $host (kindOf $cfg.ca)) }} +{{- end }} +{{- end }} +{{- end }} + +{{- /* Renders one `key: "value"` line per component extraArgs entry, at + the 6-space indent every call site sits at (cluster. + .extraArgs). Talos types the field as map[string]string, so every + value is coerced to a quoted string — an unquoted numeric would + otherwise be emitted as a YAML int Talos rejects. range sorts keys, + so the output stays deterministic. + + Shared by both presets and all three components: the guards below + are the only thing standing between a values typo and a component + that either fails to start or starts with a nonsense flag, and six + copies of them would drift. Expects a dict with "args" and "field" + (the values.yaml key name, used in messages). */ -}} +{{- define "talm.render.component_args" }} +{{- $field := .field }} +{{- range $k, $v := .args }} +{{- if kindIs "invalid" $v }} +{{- fail (printf "values.yaml: %s.%s has no value. A bare `key:` renders as the literal string \"\" onto the component command line; give it a value or drop the key." $field $k) }} +{{- end }} +{{- /* A nested map or list has no command-line form: toString would put + the Go rendering of the value (map[a:b]) on the flag. */}} +{{- if or (kindIs "map" $v) (kindIs "slice" $v) }} +{{- fail (printf "values.yaml: %s.%s is a %s, but a component flag takes a single scalar value. Write it as the string the component expects." $field $k (kindOf $v)) }} +{{- end }} + {{ $k }}: {{ $v | toString | quote }} +{{- end }} +{{- end }} + +{{- /* Talos machine.time.servers is a list of NTP hosts. A mapping there + renders as a mapping and the node rejects it, so refuse at render + with a message that names the shape. */ -}} +{{- define "talm.guard.time_servers" }} +{{- if and .Values.timeServers (not (kindIs "slice" .Values.timeServers)) }} +{{- fail (printf "values.yaml: timeServers must be a list of NTP servers (got %s). Example: timeServers: [time.cloudflare.com]." (kindOf .Values.timeServers)) }} +{{- end }} +{{- end }} + +{{- /* Multi-doc registry documents. Guards run via talm.guard.registries. */ -}} +{{- define "talm.config.registries.multidoc" }} +{{- include "talm.guard.registries" . }} +{{- range $name, $cfg := .Values.registryMirrors }} +--- +apiVersion: v1alpha1 +kind: RegistryMirrorConfig +{{- /* toYaml quotes only when YAML requires it, so an ordinary host stays + bare while the documented "*" fallback name — a YAML alias + indicator — comes out quoted instead of corrupting the document. */}} +name: {{ $name | toYaml }} +endpoints: +{{- range $cfg.endpoints }} + - url: {{ . }} +{{- end }} +{{- end }} +{{- range $host, $cfg := .Values.registryTLS }} +--- +apiVersion: v1alpha1 +kind: RegistryTLSConfig +{{- /* Same toYaml treatment the mirror name gets above: a bracketed + IPv6 endpoint host ([2001:db8::1]:5000) opens a YAML flow + sequence when interpolated raw, and the document stops + parsing. */}} +name: {{ $host | toYaml }} +{{- /* PEM verbatim here; the legacy block base64-encodes the same value + because its field decodes base64 on load. */}} +{{- with $cfg.ca }} +ca: |- +{{ . | trim | indent 2 }} +{{- end }} +{{- if not (kindIs "invalid" $cfg.insecureSkipVerify) }} +insecureSkipVerify: {{ $cfg.insecureSkipVerify }} +{{- end }} +{{- end }} +{{- end }} + +{{- /* Legacy machine.registries block (emitted at 2-space indent under + `machine:`). Guards run via talm.guard.registries. */ -}} +{{- define "talm.config.registries.legacy" }} +{{- include "talm.guard.registries" . }} +{{- if or .Values.registryMirrors .Values.registryTLS }} + registries: +{{- end }} +{{- with .Values.registryMirrors }} + mirrors: +{{- range $name, $cfg := . }} + {{ $name | toYaml }}: + endpoints: +{{- range $cfg.endpoints }} + - {{ . }} +{{- end }} +{{- end }} +{{- end }} +{{- with .Values.registryTLS }} + config: +{{- range $host, $cfg := . }} + {{ $host | toYaml }}: + tls: +{{- /* The legacy field is Base64Bytes: it base64-decodes on load, so the + operator's PEM is encoded here. */}} +{{- with $cfg.ca }} + ca: {{ . | b64enc }} +{{- end }} +{{- if not (kindIs "invalid" $cfg.insecureSkipVerify) }} + insecureSkipVerify: {{ $cfg.insecureSkipVerify }} +{{- end }} +{{- end }} +{{- end }} +{{- end }} + +{{- /* Refuse an address that is also a declared VIP. The discovery path + strips VIP ips out of a link's addresses for this exact reason: a + VIP is installed by its Layer2VIPConfig, and pinning it as a static + address too leaves the leader and the followers disagreeing about + who owns it. The declarative path cannot silently strip what the + operator wrote, so it refuses instead. Expects a dict with + "addresses", "vipIPs" and "name". */ -}} +{{- define "talm.guard.addresses_not_vip" }} +{{- $vipIPs := .vipIPs }} +{{- $name := .name }} +{{- range .addresses }} +{{- /* Talos decodes these into netip.Prefix, so a bare IP fails at DECODE + time ("no '/'") — before validation, with a message that does not + name the document. The discovery path filters malformed CIDRs; the + declarative path refuses them. */}} +{{- if not (contains "/" (. | toString)) }} +{{- fail (printf "talm: address %q on network.extraLinks %q has no prefix length. Talos parses addresses as CIDR, so write it as 203.0.113.10/24." (. | toString) $name) }} +{{- end }} +{{- $ip := (splitList "/" (. | toString)) | first }} +{{- if not (ipIsValid $ip) }} +{{- fail (printf "talm: address %q on network.extraLinks %q is not a valid IP literal." (. | toString) $name) }} +{{- end }} +{{- /* The prefix itself is parsed too: /33 on IPv4, /abc and a bare + trailing slash all pass the two checks above but fail on the node + at decode time, in a message that never names this document. + cidrPrefixLen returns -1 on any ParsePrefix failure, and the IP + half is already known good, so a negative result here is always + the prefix. */}} +{{- if lt (cidrPrefixLen (. | toString)) 0 }} +{{- fail (printf "talm: address %q on network.extraLinks %q has an invalid prefix length. Write a CIDR Talos can parse, e.g. 203.0.113.10/24 or 2001:db8::10/64." (. | toString) $name) }} +{{- end }} +{{- /* $vipIPs holds canonical forms, so canonicalise before the + membership test — otherwise an IPv6 VIP written one way here and + another way under vips slips past the check. */}} +{{- if has (ipCanonical $ip) $vipIPs }} +{{- fail (printf "talm: address %q on network.extraLinks %q is also declared as a VIP (floatingIP or vips). The VIP is installed by its Layer2VIPConfig; declaring it as a static address as well puts the leader and followers out of sync. Drop it from one side." (. | toString) $name) }} +{{- end }} +{{- end }} +{{- end }} + +{{- define "talm.render.link_routes_mtu" }} +{{- range .routes }} +{{- if not .gateway }} +{{- fail (printf "talm: a network.extraLinks routes entry on %q has no gateway. Each route needs a gateway address; omit destination for a default route." $.name) }} +{{- end }} +{{- /* gateway decodes into netip.Addr and destination into netip.Prefix, + so a malformed value fails on the node at decode time with a message + that never names this document. Refuse here instead. */}} +{{- if not (ipIsValid (.gateway | toString)) }} +{{- fail (printf "talm: route gateway %q on network.extraLinks %q is not a valid IP literal." (.gateway | toString) $.name) }} +{{- end }} +{{- with .destination }} +{{- if not (contains "/" (. | toString)) }} +{{- fail (printf "talm: route destination %q on network.extraLinks %q has no prefix length. Talos parses it as CIDR, so write it as 198.51.100.0/24." (. | toString) $.name) }} +{{- end }} +{{- if not (ipIsValid ((splitList "/" (. | toString)) | first)) }} +{{- fail (printf "talm: route destination %q on network.extraLinks %q is not a valid CIDR." (. | toString) $.name) }} +{{- end }} +{{- /* Same prefix check the address guard applies: the IP half being + valid says nothing about /999 or /foo. */}} +{{- if lt (cidrPrefixLen (. | toString)) 0 }} +{{- fail (printf "talm: route destination %q on network.extraLinks %q has an invalid prefix length. Write a CIDR Talos can parse, e.g. 198.51.100.0/24." (. | toString) $.name) }} +{{- end }} +{{- end }} +{{- end }} +{{- with .routes }} +routes: +{{- range . }} + - gateway: {{ .gateway }} + {{- with .destination }} + destination: {{ . }} + {{- end }} +{{- end }} +{{- end }} +{{- /* mtu reaches the wire as a uint32 Talos hands to the link + controller, so anything that is not a plain positive integer is a + config the node rejects — or, for a bare 0, one the truthiness + check below would silently drop. Match the digits-only form + explicitly: it rejects "jumbo", -5 and 1400.5 in one check, on + whatever numeric type the YAML parser produced. The bounds are + the kernel's own ethernet limits (ETH_MIN_MTU 68, ETH_MAX_MTU + 65535). */}} +{{- if not (kindIs "invalid" .mtu) }} +{{- $mtu := printf "%v" .mtu }} +{{- if not (regexMatch "^[0-9]+$" $mtu) }} +{{- fail (printf "talm: mtu %q on network.extraLinks %q is not a whole number. Write it as a plain integer, e.g. 9000." $mtu $.name) }} +{{- end }} +{{- if or (lt (atoi $mtu) 68) (gt (atoi $mtu) 65535) }} +{{- fail (printf "talm: mtu %s on network.extraLinks %q is outside the 68-65535 range the kernel accepts for an ethernet link." $mtu $.name) }} +{{- end }} +mtu: {{ .mtu }} +{{- end }} +{{- end }} + +{{- /* JSON list of the interface names in the running MachineConfig's + machine.network.interfaces[]. The legacy renderer emits that block + verbatim (preserveExisting or a re-apply of a legacy-applied node), + so a vips entry or vipLink override that names one of these links + would double-declare the device. Callers seed their collision + guards with this list. Empty when there is no legacy block. */ -}} +{{- define "talm.discovered.existing_interface_names" }} +{{- $names := list }} +{{- $interfaces := fromJsonArray (include "talm.discovered.existing_interfaces_raw" .) }} +{{- range $interfaces }} +{{- if .interface }} +{{- $names = append $names (.interface | toString) }} +{{- end }} +{{- end }} +{{- toJson $names }} {{- end }} {{- /* Get bond slave interfaces for a given bond index */ -}} @@ -403,10 +685,11 @@ true built once per call rather than once per address-table entry. */ -}} {{- $skipScopes := list "host" "link" "nowhere" -}} -{{- /* Track best match across iterations. dict-mutation via Sprig - set is the established pattern for cross-iteration state in - Go templates; range introduces a new scope per iteration so - a plain $var = ... reassignment does not propagate. */ -}} +{{- /* Track best match across iterations. Two values move together + here — the winning link and the prefix length that made it win — + so a dict keeps them from drifting apart across a rewrite. Plain + $var = ... reassignment would work too; the surrounding helpers + use it where a single value is tracked. */ -}} {{- $best := dict "link" "" "prefixLen" -1 -}} {{- range (lookup "addresses" "" "").items -}} {{- $address := .spec.address | toString -}} @@ -727,8 +1010,14 @@ busPath: {{ $link.spec.busPath }} MachineConfig — the renderer cannot translate those entries today and would otherwise silently drop them on the next apply. */ -}} {{- $legacyInterfaces := include "talm.discovered.existing_interfaces_configuration" . }} -{{- if $legacyInterfaces }} -{{- fail (printf "talm: the multi-doc renderer cannot translate legacy machine.network.interfaces[] from the running MachineConfig. Move the interfaces, vlans, and addresses below into per-node body overlays as v1.12 typed documents (LinkConfig, VLANConfig, BondConfig, RouteConfig) before re-running talm apply, or pin templateOptions.talosVersion to v1.11 in Chart.yaml until the translator lands.\n\nDetected legacy block:\n%s" $legacyInterfaces) }} +{{- $preserveExisting := and .Values.network .Values.network.preserveExisting }} +{{- /* preserveExisting lets the operator opt out of the guard. + The running machine.network.interfaces are emitted verbatim in the + machine document (see talos.config.machine.common) and the typed + per-link rebuild below is skipped, so a richer applied topology is + preserved instead of failing or being flattened to the primary link. */}} +{{- if and $legacyInterfaces (not $preserveExisting) }} +{{- fail (printf "talm: the multi-doc renderer cannot translate legacy machine.network.interfaces[] from the running MachineConfig. Move the interfaces, vlans, and addresses below into per-node body overlays as v1.12 typed documents (LinkConfig, VLANConfig, BondConfig, RouteConfig) before re-running talm apply, set network.preserveExisting to keep the applied interfaces verbatim, or pin templateOptions.talosVersion to v1.11 in Chart.yaml until the translator lands.\n\nDetected legacy block:\n%s" $legacyInterfaces) }} {{- end }} {{- (include "talm.discovered.physical_links_info" .) }} --- @@ -767,6 +1056,69 @@ nameservers: {{- $fipStr := .Values.floatingIP | toString }} {{- $fipIsSet := and (ne $fipStr "") (ne $fipStr "") }} {{- include "talm.validate_floatingIP" . }} +{{- /* Multi-VIP: collect every VIP ip (the single floatingIP + shorthand plus each vips[].ip) into one list. The per-link + address strip below removes all of them so no VIP leaks into a + LinkConfig.addresses, and the emit loop near the end of the + define renders one Layer2VIPConfig per vips entry. Each vips ip + gets the same ipIsValid fail-fast floatingIP gets. + + Every ip enters the list in canonical form (ipCanonical). One + IPv6 address has many spellings and Talos reports discovered + addresses canonically, so an operator who writes + `2001:0DB8::5` where discovery reports `2001:db8::5/64` must + still match — otherwise the strip below misses it and the + address ships both as a static LinkConfig entry and as a + Layer2VIPConfig, which is exactly the split-brain the strip + exists to prevent. Canonicalising here covers the duplicate + check and the extraLinks guard in one place. IPv4 has a single + spelling, so this is a no-op there. */}} +{{- $vipIPs := list }} +{{- if $fipIsSet }} +{{- $vipIPs = append $vipIPs (ipCanonical $fipStr) }} +{{- end }} +{{- range .Values.vips }} +{{- $vipIP := .ip | toString }} +{{- if not (ipIsValid $vipIP) }} +{{- fail (printf "talm: vips[].ip %q is not a valid IPv4 / IPv6 literal. Edit values.yaml and re-run." $vipIP) }} +{{- end }} +{{- /* Compare and store canonically, but keep quoting the operator's own + spelling in the messages below so the error names what they typed. */}} +{{- $vipCanon := ipCanonical $vipIP }} +{{- if not .link }} +{{- fail (printf "talm: a vips entry (ip %q) has no link. Each vips entry must name the link the VIP is pinned to." $vipIP) }} +{{- end }} +{{- if has $vipCanon $vipIPs }} +{{- /* Each Layer2VIPConfig is named after its ip, so the same ip declared + twice (across floatingIP and vips, or two vips entries) would emit + two documents with the same kind+name that Talos rejects on apply. + Fail fast at render instead. Two spellings of one IPv6 address are + the same address, so the comparison runs on the canonical form. */}} +{{- fail (printf "talm: VIP ip %q (%s) is declared more than once (across floatingIP and vips). Each VIP ip must be unique." $vipIP $vipCanon) }} +{{- end }} +{{- $vipIPs = append $vipIPs $vipCanon }} +{{- end }} +{{- /* guard: under preserveExisting the running interfaces are + emitted verbatim and the per-link VIP address-strip is skipped, so a + VIP already present inside the preserved machine.network.interfaces + would be declared twice — once verbatim and once as the + Layer2VIPConfig emitted below. Detect a VIP ip literal inside the + preserved block and fail fast so the operator drops it from one + side rather than shipping a double-pinned VIP to the live node. */}} +{{- if and $preserveExisting $legacyInterfaces }} +{{- range $vip := $vipIPs }} +{{- /* Match the VIP as a whole ip token, not a bare substring, so + 192.168.1.1 does not falsely match 192.168.1.10. Escape the dots + and bound with characters that cannot continue an ip literal + (hex digits, colon, dot); this catches both the address form + (192.0.2.5/24) and the inline `vip: { ip: 192.0.2.5 }` form, for + IPv4 and IPv6 alike. */}} +{{- $pat := printf "(^|[^0-9a-fA-F:.])%s([^0-9a-fA-F:.]|$)" ($vip | replace "." "\\.") }} +{{- if regexMatch $pat $legacyInterfaces }} +{{- fail (printf "talm: VIP %q also appears in the preserved machine.network.interfaces block (network.preserveExisting is set), so it would be declared twice — once verbatim and once as a Layer2VIPConfig. Remove the VIP from the preserved interfaces, or drop it from floatingIP/vips and let the preserved block carry it inline." $vip) }} +{{- end }} +{{- end }} +{{- end }} {{- /* Operator-declared vipLink override: emit Layer2VIPConfig regardless of discovery state. Useful when the target link does not yet exist on the live system at first apply (typical @@ -782,9 +1134,88 @@ link: {{ .Values.vipLink }} {{- end }} {{- $defaultLinkName := include "talm.discovered.default_link_name_by_gateway" . }} {{- $configurableLinks := fromJsonArray (include "talm.discovered.configurable_link_names" .) }} +{{- $existingLinkNames := fromJsonArray (include "talm.discovered.existing_interface_names" .) }} +{{- /* Links a declared bond enslaves. A slave must not also carry a + document of its own: the discovery path already drops any link + whose spec.slaveKind is set, precisely because a standalone + LinkConfig next to the master's links[] entry is a conflicting + declaration Talos rejects during controller convergence. The + declarative path has to reproduce that filter, since on the first + apply the bond does not exist on the node yet and discovery still + reports the future slave as an ordinary NIC. + + Moving an already-addressed NIC into a bond is the headline case, + so it is accepted — but only once the entry says where the + addressing goes. Filtering the slave's document while the bond + declares nothing would take the node's connectivity away, and the + render cannot infer the intent. So a slave carrying addresses + needs addresses on the bond entry, and a slave carrying the + default route needs a route there too. + + One shape cannot be resolved this way and fails outright: a slave + emitted verbatim from the running machine.network.interfaces + under preserveExisting. That block is copied as-is, so the + conflict has no chart-side fix. */}} +{{- $bondSlaveNames := list }} +{{- range $extra := (.Values.network | default dict).extraLinks }} +{{- range $slave := (($extra.bond | default dict).interfaces | default list) }} +{{- $slaveName := $slave | toString }} +{{- if has $slaveName $existingLinkNames }} +{{- fail (printf "talm: network.extraLinks bond %q enslaves %q, which the running machine.network.interfaces block already declares verbatim (network.preserveExisting). The device would be declared twice from two sources. Drop it from the preserved interfaces, or build the bond from links that block does not name." ($extra.interface | toString) $slaveName) }} +{{- end }} +{{- if has $slaveName $configurableLinks }} +{{- $slaveAddresses := fromJsonArray (include "talm.discovered.addresses_by_link" $slaveName) }} +{{- if and $slaveAddresses (not $extra.addresses) }} +{{- fail (printf "talm: network.extraLinks bond %q enslaves %q, which discovery reports carrying its own addresses (%s). A bond slave holds no addressing of its own, so this apply would take the node's connectivity away without saying where it goes. Put those addresses on the bond entry, or enslave a link that carries none." ($extra.interface | toString) $slaveName (join ", " $slaveAddresses)) }} +{{- end }} +{{- /* Every route the slave carries rides on its own document, which + this guard is about to drop, so each one has to be restated on the + bond. The default route is checked by shape rather than by + presence: an entry with a destination is a scoped route and leaves + the node with no way off its subnet, so counting any route as + proof of one would satisfy the check without achieving it. */}} +{{- $bondHasDefaultRoute := false }} +{{- $bondDestinations := list }} +{{- range $extra.routes }} +{{- $destination := (.destination | default "") | toString }} +{{- if eq $destination "" }} +{{- $bondHasDefaultRoute = true }} +{{- else }} +{{- $bondDestinations = append $bondDestinations $destination }} +{{- end }} +{{- end }} +{{- if and (eq $slaveName $defaultLinkName) (not $bondHasDefaultRoute) }} +{{- fail (printf "talm: network.extraLinks bond %q enslaves %q, which carries the node's default route. That route is emitted on the link's own document, and a slave gets none — add a destination-less route to the bond entry (routes: [{gateway: %s}]) so the node keeps its way out." ($extra.interface | toString) $slaveName (include "talm.discovered.gateway_by_link" $slaveName)) }} +{{- end }} +{{- /* Scoped routes are the quieter half of the same loss: nothing about + the node's default path changes, a subnet just stops being + reachable. routes_by_link already excludes the default route + (dst != ""); narrow to the main table because that is the only one + a RouteConfig on the bond can express. */}} +{{- $missingDestinations := list }} +{{- range fromJsonArray (include "talm.discovered.routes_by_link" $slaveName) }} +{{- if eq (.table | toString) "main" }} +{{- if not (has (.dst | toString) $bondDestinations) }} +{{- $missingDestinations = append $missingDestinations (printf "%s via %s" (.dst | toString) (.gateway | toString)) }} +{{- end }} +{{- end }} +{{- end }} +{{- if $missingDestinations }} +{{- fail (printf "talm: network.extraLinks bond %q enslaves %q, which carries static routes a slave cannot keep (%s). Restate them on the bond entry as routes[] with a matching destination, or enslave a link that carries none." ($extra.interface | toString) $slaveName (join "; " $missingDestinations)) }} +{{- end }} +{{- end }} +{{- $bondSlaveNames = append $bondSlaveNames $slaveName }} +{{- end }} +{{- end }} +{{- /* skip the typed per-link rebuild only when preserveExisting + is set AND discovery actually found a legacy interfaces block to + carry verbatim. A fresh v1.12 node with preserveExisting set has + nothing to preserve, so it must fall through to the normal rebuild + rather than emit a network-less config. */}} +{{- if not (and $preserveExisting $legacyInterfaces) }} {{- range $linkName := $configurableLinks }} {{- $link := lookup "links" "" $linkName }} -{{- if $link }} +{{- if and $link (not (has $linkName $bondSlaveNames)) }} {{- $kind := $link.spec.kind | toString }} {{- $isGatewayLink := eq $linkName $defaultLinkName }} {{- $rawAddresses := fromJsonArray (include "talm.discovered.addresses_by_link" $linkName) }} @@ -798,14 +1229,18 @@ link: {{ .Values.vipLink }} and follower configs out of sync. */}} {{- $addresses := list }} {{- range $rawAddresses }} -{{- /* Use the hoisted $fipStr/$fipIsSet from the top of the - define so the strip honours the same coerced value the - validation block above used. Going through `printf "%s/" - $.Values.floatingIP` directly would emit - `%!s(int=192168)/` for a numeric YAML scalar on a worker - render (controlplane was caught by the fail-fast). */ -}} -{{- if not (and $fipIsSet (hasPrefix (printf "%s/" $fipStr) .)) }} -{{- $addresses = append $addresses . }} +{{- /* Strip any VIP ip ($vipIPs, built from the coerced floatingIP + plus the vips list at the top of the define) from this link's + addresses. Both sides go through ipCanonical: the discovered + CIDR's host part is compared against the canonical VIP list, so + an operator spelling of an IPv6 VIP that differs from what COSI + reports still matches. Splitting on "/" rather than prefix- + matching the whole string also keeps a numeric YAML scalar + (`floatingIP: 192168`) from ever reaching printf. */ -}} +{{- $addr := . }} +{{- $addrIP := ipCanonical ((splitList "/" $addr) | first) }} +{{- if not (has $addrIP $vipIPs) }} +{{- $addresses = append $addresses $addr }} {{- end }} {{- end }} {{- $linkGateway := "" }} @@ -979,6 +1414,7 @@ mtu: {{ $link.spec.mtu }} {{- end }} {{- end }} {{- end }} +{{- end }} {{- /* Discovery-derived Layer2VIPConfig: skipped when the operator has set .Values.vipLink, since the override-path block above has already emitted the document with the operator's chosen @@ -1016,4 +1452,211 @@ name: {{ $fipStr | quote }} link: {{ $vipLink }} {{- end }} {{- end }} +{{- /* one Layer2VIPConfig per vips entry, bound to its link. + Emitted for both control-plane and worker roles so a storage + VIP on a secondary link works on any node. + + vips[].link is deliberately NOT checked for existence, unlike an + extraLinks VLAN parent. The asymmetry is intentional: Talos rejects + a VLANConfig whose parent is missing, so that guard prevents a + config the node would refuse — whereas a Layer2VIPConfig may + legitimately name a link that does not exist yet at first apply + (the same case the vipLink override exists for: a VLAN or bond this + very config is about to bring up). Checking here would break that. */}} +{{- range .Values.vips }} +--- +apiVersion: v1alpha1 +kind: Layer2VIPConfig +name: {{ .ip | quote }} +link: {{ .link }} +{{- end }} +{{- /* declarative extra links layered on the discovered + topology. A bond entry becomes a BondConfig, a plain entry with + addresses a LinkConfig, and each vlans[] entry a VLANConfig + parented on the entry's interface. An interface (or a vlan child + name) must not clash with a discovered link or another extraLinks + entry — that would emit two documents with the same kind+name that + Talos rejects on apply — so collisions fail fast at render, seeded + with the discovered configurable links. */}} +{{- /* Under preserveExisting the typed rebuild is skipped and the running + machine.network.interfaces block is emitted verbatim, so its device + names are just as taken as the discovered ones — but they do not + appear in the discovery-derived list. Seed the guard with both, or + an extraLinks entry naming a preserved device declares that device + twice from two sources. Talos does not catch a v1alpha1-vs-document + link conflict (it only checks that for kubespan and resolver), so + this has to fail here. $existingLinkNames is resolved above, where + the bond-slave guard needs it too. */}} +{{- /* An entry selected by deviceSelector has no name resolvable at render + time, so it cannot seed the guard above — the operator could be + redeclaring that very device in extraLinks and nothing would catch + it. Refuse the combination instead of guessing. */}} +{{- if (.Values.network | default dict).extraLinks }} +{{- range fromJsonArray (include "talm.discovered.existing_interfaces_raw" .) }} +{{- if not .interface }} +{{- fail "talm: the running machine.network.interfaces block selects a device by deviceSelector, whose name talm cannot resolve at render time, so it cannot check network.extraLinks against it. Name that interface explicitly in the node's applied config, or drop network.extraLinks and declare the extra links via a per-node body overlay." }} +{{- end }} +{{- end }} +{{- end }} +{{- $emittedNames := concat $configurableLinks $existingLinkNames }} +{{- /* An entry that carries a bond or addresses declares a NEW link + (BondConfig/LinkConfig); an entry with only vlans uses its + interface as the PARENT of the VLANs, not as a new link. Collect + the new-link names first so a vlans entry may parent onto a bond + declared in another entry, and so the parent-existence guard can + accept them. */}} +{{- $extraLinkNames := list }} +{{- range $extra := (.Values.network | default dict).extraLinks }} +{{- if or $extra.bond $extra.addresses }} +{{- $extraLinkNames = append $extraLinkNames ($extra.interface | toString) }} +{{- end }} +{{- end }} +{{- range $extra := (.Values.network | default dict).extraLinks }} +{{- if not $extra.interface }} +{{- /* The emitted document's name comes straight from this field; without + it Talos gets `name:` empty and rejects with "name must be + specified". Refuse before that. */}} +{{- fail "talm: a network.extraLinks entry has no interface. Each entry must name the link it declares, or the link its vlans hang off." }} +{{- end }} +{{- $isNewLink := or $extra.bond $extra.addresses }} +{{- if not $isNewLink }} +{{- /* mtu and routes describe a link. An entry that declares none (no + bond, no addresses) has nothing to attach them to — the discovered + link already emits its own document, and a second one with the same + name would collide. Refuse rather than drop them silently. */}} +{{- if or $extra.mtu $extra.routes }} +{{- fail (printf "talm: network.extraLinks entry %q sets mtu or routes but declares no link. Add addresses or a bond to declare it, put them on the vlans[] child they belong to, or set them via a per-node body overlay for an already-discovered link." ($extra.interface | toString)) }} +{{- end }} +{{- if not $extra.vlans }} +{{- fail (printf "talm: network.extraLinks entry %q declares nothing. An entry needs addresses or a bond to create a link, or vlans to hang VLANs off an existing one." ($extra.interface | toString)) }} +{{- end }} +{{- end }} +{{- include "talm.guard.addresses_not_vip" (dict "addresses" $extra.addresses "vipIPs" $vipIPs "name" ($extra.interface | toString)) }} +{{- if $isNewLink }} +{{- if has ($extra.interface | toString) $emittedNames }} +{{- fail (printf "talm: network.extraLinks interface %q collides with a discovered link, the preserved machine.network.interfaces block, or another extraLinks entry; Talos rejects the duplicate document name on apply. Rename the interface or remove the duplicate." ($extra.interface | toString)) }} +{{- end }} +{{- $emittedNames = append $emittedNames ($extra.interface | toString) }} +{{- if $extra.bond }} +{{- if not $extra.bond.interfaces }} +{{- /* Mirror the discovery-derived bond guard: Talos rejects a BondConfig + with an empty links list ("at least one link must be specified"), + so fail at render with a precise message rather than emitting + links: null that only fails later on the live node. */}} +{{- fail (printf "talm: network.extraLinks entry %q declares a bond with no interfaces. A BondConfig needs at least one link; add bond.interfaces or drop the bond." ($extra.interface | toString)) }} +{{- end }} +{{- if not $extra.bond.mode }} +{{- /* Same Validate() as the empty-links case two lines up: Talos returns + "bond mode must be specified" when bondMode is unset. mode is + required, not part of the optional tuning set. */}} +{{- fail (printf "talm: network.extraLinks entry %q declares a bond with no mode. Talos rejects a BondConfig without bondMode; set bond.mode (e.g. 802.3ad or active-backup)." ($extra.interface | toString)) }} +{{- end }} +{{- $bondModes := list "balance-rr" "active-backup" "balance-xor" "broadcast" "802.3ad" "balance-tlb" "balance-alb" }} +{{- if not (has ($extra.bond.mode | toString) $bondModes) }} +{{- fail (printf "talm: network.extraLinks entry %q declares bond mode %q, which Talos does not know (it fails with \"unknown bond mode\"). Valid modes: %s." ($extra.interface | toString) ($extra.bond.mode | toString) (join ", " $bondModes)) }} +{{- end }} +{{- /* A slave declared as a link in its own right elsewhere in + extraLinks gets a LinkConfig alongside this bond's links[] entry — + the same conflicting pair the discovery-side filter above prevents, + except here both halves are operator-written. The discovery-side + cases are handled before the per-link loop. */}} +{{- range $extra.bond.interfaces }} +{{- if has (. | toString) $extraLinkNames }} +{{- fail (printf "talm: network.extraLinks bond %q enslaves %q, which another extraLinks entry declares as a link of its own. A bond slave carries no configuration of its own; drop the separate entry or build the bond from another link." ($extra.interface | toString) (. | toString)) }} +{{- end }} +{{- end }} +--- +apiVersion: v1alpha1 +kind: BondConfig +name: {{ $extra.interface }} +links: +{{- range $extra.bond.interfaces }} + - {{ . }} +{{- end }} +{{- if $extra.bond.mode }} +bondMode: {{ $extra.bond.mode }} +{{- end }} +{{- /* Bond tuning, mirroring the fields the discovery-derived BondConfig + already emits so a declared bond is not a downgrade from a + discovered one. Names are Talos's own BondConfig keys. */}} +{{- if $extra.bond.xmitHashPolicy }} +xmitHashPolicy: {{ $extra.bond.xmitHashPolicy }} +{{- end }} +{{- if $extra.bond.lacpRate }} +lacpRate: {{ $extra.bond.lacpRate }} +{{- end }} +{{- if $extra.bond.miimon }} +miimon: {{ $extra.bond.miimon }} +{{- end }} +{{- if $extra.bond.updelay }} +updelay: {{ $extra.bond.updelay }} +{{- end }} +{{- if $extra.bond.downdelay }} +downdelay: {{ $extra.bond.downdelay }} +{{- end }} +{{- with $extra.addresses }} +addresses: +{{- range . }} + - address: {{ . }} +{{- end }} +{{- end }} +{{- include "talm.render.link_routes_mtu" (dict "routes" $extra.routes "mtu" $extra.mtu "name" ($extra.interface | toString)) }} +{{- else }} +--- +apiVersion: v1alpha1 +kind: LinkConfig +name: {{ $extra.interface }} +addresses: +{{- range $extra.addresses }} + - address: {{ . }} +{{- end }} +{{- include "talm.render.link_routes_mtu" (dict "routes" $extra.routes "mtu" $extra.mtu "name" ($extra.interface | toString)) }} +{{- end }} +{{- end }} +{{- range $vlan := $extra.vlans }} +{{- if not $vlan.vlanId }} +{{- /* VLANConfig requires vlanID on the wire; without it the emitted + document has name "." and vlanID: , which Talos + rejects. Fail fast with a precise message. */}} +{{- fail (printf "talm: network.extraLinks entry %q declares a VLAN with no vlanId. VLANConfig requires vlanID; add vlanId to the vlans entry." ($extra.interface | toString)) }} +{{- end }} +{{- /* The range check below truncates through `int`, so a fractional + 7.5 would pass as 7 while the emitted document keeps 7.5 — both + as vlanID and inside the document name (eth0.7.5). Talos rejects + that at decode. Check the digits-only form before the range. */}} +{{- if not (regexMatch "^[0-9]+$" (printf "%v" $vlan.vlanId)) }} +{{- fail (printf "talm: network.extraLinks entry %q declares vlanId %v, which is not a whole number. Talos parses vlanID as an integer; write it as e.g. 7." ($extra.interface | toString) $vlan.vlanId) }} +{{- end }} +{{- if or (lt ($vlan.vlanId | int) 1) (gt ($vlan.vlanId | int) 4094) }} +{{- fail (printf "talm: network.extraLinks entry %q declares vlanId %v, outside the valid range. Talos requires vlanID between 1 and 4094." ($extra.interface | toString) $vlan.vlanId) }} +{{- end }} +{{- include "talm.guard.addresses_not_vip" (dict "addresses" $vlan.addresses "vipIPs" $vipIPs "name" (printf "%s.%v" ($extra.interface | toString) $vlan.vlanId)) }} +{{- $parent := $extra.interface | toString }} +{{- if not (or (has $parent $configurableLinks) (has $parent $extraLinkNames) (has $parent $existingLinkNames)) }} +{{- /* A VLAN's parent must actually exist, or Talos rejects the + VLANConfig on apply ("parent link not found"). The parent is + either a discovered configurable link or a bond/link declared + elsewhere in extraLinks. */}} +{{- fail (printf "talm: network.extraLinks VLAN on parent %q has no such link — the parent must be a discovered link or a bond/link declared in extraLinks. Add the parent link or fix the interface name." $parent) }} +{{- end }} +{{- $vlanName := printf "%s.%v" $parent $vlan.vlanId }} +{{- if has $vlanName $emittedNames }} +{{- fail (printf "talm: network.extraLinks VLAN %q collides with a discovered link or another emitted document; Talos rejects the duplicate name on apply. Rename or remove the duplicate." $vlanName) }} +{{- end }} +{{- $emittedNames = append $emittedNames $vlanName }} +--- +apiVersion: v1alpha1 +kind: VLANConfig +name: {{ $parent }}.{{ $vlan.vlanId }} +vlanID: {{ $vlan.vlanId }} +parent: {{ $parent }} +{{- with $vlan.addresses }} +addresses: +{{- range . }} + - address: {{ . }} +{{- end }} +{{- end }} +{{- include "talm.render.link_routes_mtu" (dict "routes" $vlan.routes "mtu" $vlan.mtu "name" $vlanName) }} +{{- end }} +{{- end }} {{- end }} diff --git a/docs/manual-test-plan.md b/docs/manual-test-plan.md index d2dd5b5..4e41520 100644 --- a/docs/manual-test-plan.md +++ b/docs/manual-test-plan.md @@ -491,6 +491,202 @@ Collision check: with `tcpKeepaliveTuning: true`, set `extraSysctls: { "net.ipv4 Regression anchor: `TestContract_Machine_Sysctls_DRBDTuning_Cozystack`, `TestContract_Machine_Sysctls_TCPKeepalive_*`, and `TestContract_Cluster_Etcd_QuotaBackendBytes_*` pin every branch above; `TestContract_Machine_Sysctls_DRBDTuning_AbsentOnGeneric` / `TestContract_Cluster_Etcd_QuotaBackendBytes_AbsentOnGeneric` pin that the generic preset stays free of these opinions. +### B10. Values knobs for richer instance description + +These knobs let a node's specifics live in values instead of a forked or hand-edited template. All default empty, so a stock render is unchanged — the golden snapshots (`TestGoldenRender`) stay byte-identical and act as the backward-compat guard. + +#### B10a. NTP servers + +```bash +# values.yaml: timeServers: [192.0.2.1] +talm template -f nodes/controlplane-0.yaml | yq '.machine.time.servers' +``` + +Expected: `[192.0.2.1]`. Empty `timeServers` (the default) omits `machine.time` entirely. + +Regression anchor: `TestContract_Machine_TimeServers_RenderWhenSet` / `TestContract_Machine_TimeServers_EmptyByDefault`. + +#### B10b. Control-plane extraArgs + +```bash +# values.yaml: extraApiServerArgs: { max-requests-inflight: "2000" } +talm template -f nodes/controlplane-0.yaml | yq '.cluster.apiServer.extraArgs' +``` + +Expected: includes `max-requests-inflight: "2000"`. On cozystack the block appears even without `oidcIssuerUrl` (it is hoisted out of the OIDC guard). A key colliding with a preset built-in — controllerManager `bind-address`/`allocate-node-cidrs`/`cluster-cidr`, scheduler `bind-address`, or an apiServer `oidc-*` arg while `oidcIssuerUrl` is set — fails the render with a hinted message. Generic exposes the same three knobs as a plain passthrough with no built-ins to collide with. + +Regression anchor: `TestContract_Cluster_ExtraControlPlaneArgs_Merge_Cozystack`, `TestContract_Cluster_ExtraApiServerArgs_WithoutOIDC_Cozystack`, `TestContract_Cluster_ExtraControllerManagerArgs_Collision_Cozystack`, `TestContract_Cluster_ExtraApiServerArgs_OIDCCollision_Cozystack`, `TestContract_Cluster_ExtraControlPlaneArgs_Generic`. + +#### B10c. Registry mirrors + +```bash +# values.yaml: +# registryMirrors: +# docker.io: { endpoints: [https://mirror.gcr.io] } +# ghcr.io: { endpoints: [https://registry.example.com/v2/ghcr.io] } +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "RegistryMirrorConfig")' +``` + +Expected (multidoc): one `RegistryMirrorConfig` document per host. The legacy schema emits the same under `machine.registries.mirrors`. The `docker.io` → gcr default is preserved when the knob is unset. To emit no registry config at all, null the key out (`registryMirrors:` with no value); because per-node overrides merge deeply, `{}` keeps the default docker.io mirror rather than clearing it. The knob itself works on both presets; only the shipped `docker.io` default is cozystack-only — generic emits nothing until the operator sets a mirror. + +Regression anchor: `TestContract_Machine_RegistryMirrors_CustomMirror_Cozystack`, `TestContract_Machine_Registries_DockerMirror_LegacyCozystack` (default), `TestContract_Machine_NoRegistriesOnGeneric`. + +A self-signed pull-through mirror also needs its TLS posture stated. `registryTLS` is keyed by the endpoint HOST, not the mirror name: + +```bash +# values.yaml: +# registryMirrors: +# docker.io: { endpoints: [https://registry.example.com/v2/docker.io] } +# registryTLS: +# registry.example.com: { insecureSkipVerify: true } +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "RegistryTLSConfig")' +``` + +Expected (multidoc): one `RegistryTLSConfig` document per host. The legacy schema emits the same under `machine.registries.config..tls`. Every entry must set `ca`, `insecureSkipVerify`, or both — an empty entry fails the render rather than silently picking a security posture. Setting `insecureSkipVerify: false` is emitted verbatim, so verification can be pinned on. + +The `ca` path is worth one extra check, because the two schemas disagree on encoding and the chart hides that. Supply the CA as PEM and render both ways: + +```bash +# values.yaml: +# registryTLS: +# registry.example.com: +# ca: | +# -----BEGIN CERTIFICATE----- +# ... +# -----END CERTIFICATE----- +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "RegistryTLSConfig")' +# then re-render with templateOptions.talosVersion pinned to v1.11 +talm template -f nodes/controlplane-0.yaml | yq '.machine.registries.config' +``` + +Expected: multidoc carries the PEM verbatim under `ca: |-`; legacy carries the SAME certificate base64-encoded, because that field base64-decodes on load. A raw PEM in the legacy field is a bug — Talos fails to decode it. Pull an image on the node afterwards to confirm the registry is actually reachable; the render cannot prove trust is established. + +An endpoint on an IPv6 literal carries the bracketed `host:port` form, and `[` opens a YAML flow sequence — so the key has to survive as a key. Render with one and parse the result rather than reading it: + +```bash +# values.yaml: registryTLS: { "[2001:db8::1]:5000": { insecureSkipVerify: true } } +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "RegistryTLSConfig")' +# then re-render with templateOptions.talosVersion pinned to v1.11 +talm template -f nodes/controlplane-0.yaml | yq '.machine.registries.config' +``` + +Expected: both parse, with the bracketed host intact as the document name and as the map key. An ordinary `host:port` stays unquoted. + +Regression anchor: `TestContract_Machine_RegistryTLS_Multidoc_Cozystack`, `TestContract_Machine_RegistryTLS_Legacy_Cozystack`, `TestContract_Machine_RegistryTLS_CA_Multidoc_Cozystack`, `TestContract_Machine_RegistryTLS_CA_Legacy_Cozystack`, `TestContract_Machine_RegistryTLS_MissingFlag_Fails_Cozystack`. `TestContract_Machine_RegistryTLS_IPv6EndpointHost_Multidoc_Cozystack`, `TestContract_Machine_RegistryTLS_IPv6EndpointHost_Legacy_Cozystack`. + +#### B10d. Multiple Layer2 VIPs + +```bash +# values.yaml: +# vips: +# - { link: bond0, ip: 192.0.2.254 } +# - { link: bond1, ip: 203.0.113.254 } +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "Layer2VIPConfig")' +``` + +Expected (multidoc): one `Layer2VIPConfig` per `vips` entry, each on its declared link. The legacy schema emits one interface entry with an inline `vip` per entry. `floatingIP`/`vipLink` still work as the single-VIP shorthand and can be combined with `vips`. Any VIP ip that leaks into a link's discovered addresses is stripped from that link. A malformed `vips[].ip` fails the render. + +One IPv6 address has several spellings and Talos reports the canonical one, so the strip has to survive an operator writing it differently. On a node whose discovered addresses include an IPv6 VIP, set `floatingIP` to the same address in upper case with a padded group: + +```bash +# values.yaml: floatingIP: 2001:0DB8::5 (node reports 2001:db8::5/128) +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "LinkConfig") | .addresses' +``` + +Expected: the VIP address is absent from every `LinkConfig.addresses`, and the `Layer2VIPConfig` is still emitted. Declaring the same address under both `floatingIP` and `vips` in two different spellings aborts the render as a duplicate. + +Regression anchor: `TestContract_NetworkMultidoc_MultiVIP_Cozystack`, `TestContract_NetworkMultidoc_MultiVIP_InvalidIP_Cozystack`, `TestContract_NetworkLegacy_MultiVIP_Cozystack`. `TestContract_NetworkMultidoc_VipStrippedRegardlessOfSpelling`, `TestContract_NetworkMultidoc_VipDuplicateAcrossSpellings_Fails`, `TestContract_NetworkLegacy_MultiVIP_DuplicateIPAcrossSpellings_Fails_Cozystack`. + +#### B10e. Full network topology + +Two ways to express topology richer than the discovered primary link. Both need a live-node check on first apply, since the render tests cannot confirm Talos accepts the resulting document set. + +Preserve-existing (A) — carry the running node's applied interfaces verbatim: + +```bash +# values.yaml: network: { preserveExisting: true } +talm template -f nodes/controlplane-0.yaml | yq '.machine.network.interfaces' +``` + +Expected: the node's running `machine.network.interfaces`, and no typed per-link documents (`LinkConfig`/`BondConfig`/`VLANConfig`) from the rebuild. With `preserveExisting` off (the default) a node still carrying a legacy interfaces block fails fast as before. Apply to a node whose applied topology is richer than the discovered primary link and confirm the interfaces round-trip unchanged. + +`preserveExisting` + a VIP (`floatingIP` or `vips`) layer safely. Because `preserveExisting` skips the per-link rebuild, it also skips the rebuild's VIP address-strip: the VIP still emits as its own `Layer2VIPConfig`, layered on top of the preserved interfaces. That is correct when the preserved `machine.network.interfaces` block does not itself declare the same VIP. If it does, the render fails fast (`VIP "…" also appears in the preserved machine.network.interfaces block`) rather than shipping a VIP declared twice — the operator drops it from one side. Verify: with both set and the VIP absent from the preserved block, `talm template ... | yq 'select(.kind == "Layer2VIPConfig")'` shows the VIP once; put the same VIP inside the preserved interfaces and the render aborts. Regression anchors: `TestContract_NetworkMultidoc_PreserveExisting_WithVIP_Cozystack`, `TestContract_NetworkMultidoc_PreserveExisting_VIPInPreservedBlock_Fails_Cozystack`. + +Declarative extraLinks (B) — declare bonds/VLANs/addresses in git: + +```bash +# values.yaml: +# network: +# extraLinks: +# - interface: bond1 +# bond: { interfaces: [enp3s0, enp4s0], mode: 802.3ad } +# addresses: [203.0.113.10/24] +# - interface: bond1 # VLAN parented on the bond declared above +# vlans: [{ vlanId: 7, addresses: [198.51.100.10/24] }] +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "BondConfig" or .kind == "VLANConfig")' +``` + +Expected: one `BondConfig` for bond1 (links, bondMode, address) and one `VLANConfig` `bond1.7` (vlanID 7, parent bond1). A vlans-only entry parents onto an existing link — a discovered link or one declared in another extraLinks entry, as `bond1` is here; a VLAN whose parent is neither fails fast at render. These layer on the discovered topology rather than replacing it. + +Regression anchor: `TestContract_NetworkMultidoc_PreserveExisting_Cozystack`, `TestContract_NetworkMultidoc_PreserveExisting_OffStillFails_Cozystack`, `TestContract_NetworkMultidoc_ExtraLinks_Cozystack`. + +A declared link is not limited to addresses. A bond requires `mode` and beyond that takes the same tuning the discovery path reconstructs; both an entry and each VLAN child take `mtu` and `routes` — which is what an externally-routed tagged uplink needs: + +```bash +# values.yaml: +# network: +# extraLinks: +# - interface: bond1 +# mtu: 1400 +# bond: +# interfaces: [enp3s0, enp4s0] +# mode: 802.3ad +# xmitHashPolicy: layer2+3 +# lacpRate: slow +# miimon: 100 +# addresses: [203.0.113.10/24] +# vlans: +# - vlanId: 217 +# addresses: [198.51.100.10/27] +# mtu: 1400 +# routes: +# - gateway: 198.51.100.1 +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "BondConfig" or .kind == "VLANConfig")' +``` + +Expected: the `BondConfig` carries `bondMode`, `xmitHashPolicy`, `lacpRate`, `miimon` and `mtu`; the `VLANConfig` carries its own `mtu` and a `routes` entry with the gateway. Several inputs Talos would reject on load are refused at render instead: a bond without `mode`, a `vlanId` outside 1-4094, an address with no prefix length, an entry with no `interface`, and a route with no gateway. Omit `destination` for a default route; set it for a specific prefix. Verify on the node that the bond negotiates LACP and the VLAN's default route installs — the render cannot confirm either. + +The two halves of this section combine, and that combination has its own trap. Under `preserveExisting` the running interfaces are emitted verbatim while `extraLinks` keeps emitting typed documents, so both can name the same device from two different sources — and Talos does not reject a v1alpha1-vs-document link conflict, it just lets the network controllers arbitrate. Check it on a node whose preserved block names a device discovery does not return: + +```bash +# values.yaml: network: { preserveExisting: true, extraLinks: [{ interface: , addresses: [203.0.113.10/24] }] } +talm template -f nodes/controlplane-0.yaml +``` + +Expected: the render aborts with `collides with a discovered link, the preserved machine.network.interfaces block, or another extraLinks entry`. Point the same entry at a device that is NOT in the preserved block and it renders, emitting the preserved interfaces plus a `LinkConfig` for the new device. Two further refusals share this reasoning: an entry that sets `mtu`/`routes` without declaring a link (no `addresses`, no `bond`) aborts rather than silently emitting nothing, and an address that is also a declared VIP aborts rather than pinning the VIP both statically and via its `Layer2VIPConfig`. + +A bond built from links the node already carries needs one more check. Discovery drops a link once it is enslaved, but on the first apply the bond does not exist yet, so the render has to do the same filtering itself. Point a bond at two spare NICs and at the primary in turn: + +```bash +# values.yaml: network: { extraLinks: [{ interface: bond1, bond: { interfaces: [], mode: 802.3ad }, addresses: [203.0.113.10/24] }] } +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "LinkConfig" or .kind == "BondConfig") | .name' +``` + +Expected: the slaves appear only under `BondConfig.links`, never as a document name of their own; the primary NIC keeps its `LinkConfig`. Swap one slave for the primary NIC and the render aborts, naming the link and the addresses it carries. + +Then follow the remediation the message prescribes — the headline #226 scenario, moving the primary NIC into a bond: + +```bash +# values.yaml: network: { extraLinks: [{ interface: bond0, bond: { interfaces: [, ], mode: 802.3ad }, addresses: [], routes: [{ gateway: }] }] } +talm template -f nodes/controlplane-0.yaml | yq 'select(.kind == "LinkConfig" or .kind == "BondConfig")' +``` + +Expected: this renders. The bond carries the addresses and the default route, and neither slave has a document. Drop the `routes` entry and the render aborts naming the gateway to restate — the default route rides on the link document the filter removes, so it has to be put back by hand. The check reads the route's shape, not the list's length: give the entry a `destination` and it still aborts, because a scoped route is not a way off the subnet. A member holding static routes of its own has to have each restated with a matching `destination`, or the render names what would go unreachable. This one is worth applying on a real node: the render cannot prove the node keeps its route across the switch to a bond. Same refusals apply to a slave that another `extraLinks` entry declares as a link, and to one the preserved `machine.network.interfaces` block already names. + +Numeric and CIDR fields are checked before they reach the node, where the decode error would not name the document. Each of these aborts at render: an address or route destination whose prefix does not parse (`203.0.113.10/33`, `198.51.100.0/foo`), an `mtu` that is not a whole number in 68-65535 (`jumbo`, `-5`, `99999`, and a bare `0`, which would otherwise be dropped without a word), and a fractional `vlanId` (`7.5` truncates to 7 in the range check but renders as `vlanID: 7.5` on a document named `eth0.7.5`). + +Regression anchor: `TestContract_NetworkMultidoc_ExtraLinks_BondTuning_Cozystack`, `TestContract_NetworkMultidoc_ExtraLinks_MtuAndRoutes_Cozystack`, `TestContract_NetworkMultidoc_ExtraLinks_VlanMtuAndRoutes_Cozystack`, `TestContract_NetworkMultidoc_ExtraLinks_RouteNoGateway_Fails_Cozystack`, `TestContract_NetworkMultidoc_ExtraLinks_CollidesWithPreserved_Fails_Cozystack`, `TestContract_NetworkMultidoc_ExtraLinks_MtuOnly_Fails_Cozystack`, `TestContract_NetworkMultidoc_ExtraLinks_AddressIsVIP_Fails_Cozystack`, `TestContract_NetworkMultidoc_ExtraLinksBondSlaveGetsNoOwnDocument`, `TestContract_NetworkMultidoc_ExtraLinksBondSlaveWithAddresses_Fails`, `TestContract_NetworkMultidoc_ExtraLinksBondSlaveAddressesMovedOntoBond`, `TestContract_NetworkMultidoc_ExtraLinksBondSlaveDefaultRouteNotMoved_Fails`, `TestContract_NetworkMultidoc_ExtraLinksBondSlaveScopedRouteIsNotDefault_Fails`, `TestContract_NetworkMultidoc_ExtraLinksBondSlaveStaticRouteNotMoved_Fails`, `TestContract_NetworkMultidoc_ExtraLinksAddressBadPrefix_Fails`, `TestContract_NetworkMultidoc_ExtraLinksBadMTU_Fails`, `TestContract_NetworkMultidoc_ExtraLinksFractionalVlanID_Fails`. + ## C. Apply (auth path) This section is the smoke-test for the apply pipe itself; the per-gate matrix lives in **Section C-safety** below. diff --git a/pkg/applycheck/refs.go b/pkg/applycheck/refs.go index cf1bd6f..cbcac26 100644 --- a/pkg/applycheck/refs.go +++ b/pkg/applycheck/refs.go @@ -36,6 +36,13 @@ const ( // RefKindDiskSelector is an install/user-volume disk identified by a // selector (size, model, serial, wwid, modalias, type, busPath). RefKindDiskSelector + // RefKindLinkCreated is a virtual link this very config creates + // (BondConfig/BridgeConfig/VLANConfig/... .name). It is never + // validated against the node — the link is not supposed to exist yet. + // It is collected so that references to it from OTHER documents in the + // same config (VLANConfig.parent, Layer2VIPConfig.link, BondConfig + // .links[]) resolve instead of being reported as missing. + RefKindLinkCreated ) // DiskSelector mirrors the Talos v1alpha1 InstallDiskSelector schema (also @@ -193,10 +200,15 @@ var multidocHandlers = map[string]multidocHandler{ "DHCPv4Config": handleNameOnly, "DHCPv6Config": handleNameOnly, "EthernetConfig": handleNameOnly, - // WireguardConfig / DummyLinkConfig / LinkAliasConfig describe - // virtual links being created. Their .name is the new resource, - // not an existing-link reference — intentionally not in the - // dispatch table so they don't get a name-based Phase 1 finding. + // DummyLinkConfig / LinkAliasConfig describe virtual links being + // created. Their .name is the new resource, not an existing-link + // reference, so it is recorded as created rather than validated — + // that way a VLAN or VIP pointing at one of them in the same config + // resolves. WireguardConfig also creates a link but is deliberately + // absent here: it belongs to the parallel net-addr walker, and the + // two dispatch maps must stay disjoint or a kind gets double-walked. + "DummyLinkConfig": handleCreatorOnly, + "LinkAliasConfig": handleCreatorOnly, "UserVolumeConfig": handleUserVolume, } @@ -216,12 +228,36 @@ func handleNameOnly(refs []Ref, doc map[string]any, basePath string) []Ref { return appendNameRef(refs, doc, basePath) } -// handleListOnly emits only the list-valued slaves/ports of the doc, -// not the doc's own .name. Used for BondConfig (its .name describes a -// virtual bond being created by the apply; the .links[] members are -// pre-existing physical NICs that must be present). +// appendCreatedRef records doc["name"] as a link this config brings into +// existence, so later references to it resolve. +func appendCreatedRef(refs []Ref, doc map[string]any, basePath string) []Ref { + name, ok := doc["name"].(string) + if !ok || name == "" { + return refs + } + + return append(refs, Ref{Kind: RefKindLinkCreated, Name: name, Source: basePath + ".name"}) +} + +// handleCreatorOnly records the doc's own .name as a created link and +// validates nothing else. Used by the link kinds whose .name is the new +// resource and which carry no reference to an existing link. +func handleCreatorOnly(refs []Ref, doc map[string]any, basePath string) []Ref { + return appendCreatedRef(refs, doc, basePath) +} + +// handleListOnly records the doc's own .name as a link this config +// creates, then emits one existing-link ref per entry of its list-valued +// slaves/ports. Used for BondConfig and BridgeConfig: the .name +// describes a virtual link the apply brings into existence — validating +// it against the node would reject every bond on its first apply, while +// recording it lets a VLAN or VIP elsewhere in the same config resolve +// against it. The .links[] members are pre-existing physical NICs that +// must already be present. func handleListOnly(listKey string) multidocHandler { return func(refs []Ref, doc map[string]any, basePath string) []Ref { + refs = appendCreatedRef(refs, doc, basePath) + return appendListRefs(refs, doc, listKey, basePath+"."+listKey) } } @@ -232,6 +268,8 @@ func handleListOnly(listKey string) multidocHandler { // exist. The YAML key in v1alpha1 is `parent`, not `link` // (vlan.go ParentLinkConfig `yaml:"parent"`). func handleParentOnly(refs []Ref, doc map[string]any, basePath string) []Ref { + refs = appendCreatedRef(refs, doc, basePath) + parent, ok := doc["parent"].(string) if !ok || parent == "" { return refs diff --git a/pkg/applycheck/validate.go b/pkg/applycheck/validate.go index ac02c2d..fbefa47 100644 --- a/pkg/applycheck/validate.go +++ b/pkg/applycheck/validate.go @@ -14,6 +14,8 @@ package applycheck +import "strings" + // HostSnapshot captures the host-side resource inventory the validator // compares declared refs against. Fields are populated from COSI // `links` and `disks` reads at apply time; tests construct fakes @@ -98,6 +100,8 @@ func ValidateRefs(refs []Ref, snapshot HostSnapshot) []Finding { linkSet[name] = struct{}{} } + unionCreatedLinks(linkSet, refs) + // Disk-literal validation accepts DevPath (`/dev/sda`) and every // stable Symlink alternative (/dev/disk/by-id/wwn-…, by-path/…, // by-diskseq/…). The recommended Talos pattern is by-id, so the @@ -123,12 +127,97 @@ func ValidateRefs(refs []Ref, snapshot HostSnapshot) []Finding { findings = appendIfMissing(findings, ref, diskPaths, diskPathList(snapshot.Disks), "disk") case RefKindDiskSelector: findings = appendSelectorFinding(findings, ref, snapshot.Disks) + case RefKindLinkCreated: + // Nothing to validate: this names a link the apply itself + // creates, so it is not expected on the node. Its only job was + // to seed linkSet above. } } return findings } +// unionCreatedLinks adds every link the config brings into existence to +// linkSet. A config may create virtual links (bond, bridge, VLAN, +// wireguard, dummy, alias) and reference them from its own other +// documents in the same apply; those names are legitimate targets even +// though the node does not carry them yet. +// +// A LinkAliasConfig name may end in a %d verb, which Talos expands into +// one sequential alias per matched link (net0, net1, ...). The literal +// name is then the one thing that never exists, while the expansions are +// exactly what other documents reference — so a pattern contributes its +// prefix instead of its name, and the second pass admits the references +// that match it. +func unionCreatedLinks(linkSet map[string]struct{}, refs []Ref) { + var aliasPrefixes []string + + for i := range refs { + if refs[i].Kind != RefKindLinkCreated { + continue + } + + if prefix, ok := aliasPatternPrefix(refs[i].Name); ok { + aliasPrefixes = append(aliasPrefixes, prefix) + + continue + } + + linkSet[refs[i].Name] = struct{}{} + } + + if len(aliasPrefixes) == 0 { + return + } + + for i := range refs { + if refs[i].Kind != RefKindLink { + continue + } + + for _, prefix := range aliasPrefixes { + if matchesAliasPattern(refs[i].Name, prefix) { + linkSet[refs[i].Name] = struct{}{} + + break + } + } + } +} + +// aliasPatternPrefix reports whether a created-link name is a +// LinkAliasConfig pattern, and returns the prefix its expansions carry. +// The accepted shape mirrors machinery's own validation: a non-empty +// prefix followed by a single trailing %d verb (link_alias.go +// IsPatternAlias + Validate). Anything else — no verb at all, or a verb +// machinery rejects — is an ordinary name. +func aliasPatternPrefix(name string) (string, bool) { + prefix, suffix, found := strings.Cut(name, "%") + if !found || suffix != "d" || prefix == "" { + return "", false + } + + return prefix, true +} + +// matchesAliasPattern reports whether a link name is one of the +// expansions of an alias pattern with the given prefix: the prefix +// followed by at least one digit and nothing else. +func matchesAliasPattern(name, prefix string) bool { + rest, ok := strings.CutPrefix(name, prefix) + if !ok || rest == "" { + return false + } + + for _, r := range rest { + if r < '0' || r > '9' { + return false + } + } + + return true +} + // appendIfMissing appends a blocker finding when ref.Name isn't in present. // available is the sorted-by-the-caller list shown to the operator so they // can pick the right name without re-running discovery. diff --git a/pkg/applycheck/validate_test.go b/pkg/applycheck/validate_test.go index 03cb91d..1955d65 100644 --- a/pkg/applycheck/validate_test.go +++ b/pkg/applycheck/validate_test.go @@ -561,3 +561,209 @@ func TestValidateRefs_EmptyRefs_NoFindings(t *testing.T) { t.Errorf("ValidateRefs(nil) returned %d findings, want 0", len(findings)) } } + +// A config that declares a virtual link and then references it from +// another document in the same apply must not be flagged. The bond does +// not exist on the node yet — this very apply creates it — so the VLAN +// parented on it and the VIP pinned to it are both legitimate. +func TestValidateRefs_DeclaredLinkSatisfiesLaterReferences(t *testing.T) { + t.Parallel() + + rendered := []byte(`apiVersion: v1alpha1 +kind: BondConfig +name: bond1 +links: + - eth0 +bondMode: 802.3ad +--- +apiVersion: v1alpha1 +kind: VLANConfig +name: bond1.7 +vlanID: 7 +parent: bond1 +--- +apiVersion: v1alpha1 +kind: Layer2VIPConfig +name: "192.0.2.254" +link: bond1 +--- +apiVersion: v1alpha1 +kind: Layer2VIPConfig +name: "198.51.100.254" +link: bond1.7 +`) + + refs, err := applycheck.WalkRefs(rendered) + if err != nil { + t.Fatalf("WalkRefs: %v", err) + } + + // The node carries only eth0; bond1 and bond1.7 are created by this apply. + findings := applycheck.ValidateRefs(refs, applycheck.HostSnapshot{Links: []string{"eth0"}}) + + for i := range findings { + f := &findings[i] + if f.IsBlocker() { + t.Errorf("a link declared in this config must satisfy references to it, got blocker: %s (%s)", f.Reason, f.Ref.Source) + } + } +} + +// The creator-name union must not turn the gate off: a reference to a link +// that neither exists on the node nor is created by this config still blocks. +func TestValidateRefs_UndeclaredLinkStillBlocks(t *testing.T) { + t.Parallel() + + rendered := []byte(`apiVersion: v1alpha1 +kind: BondConfig +name: bond1 +links: + - eth0 +bondMode: 802.3ad +--- +apiVersion: v1alpha1 +kind: VLANConfig +name: typo.7 +vlanID: 7 +parent: bond9 +`) + + refs, err := applycheck.WalkRefs(rendered) + if err != nil { + t.Fatalf("WalkRefs: %v", err) + } + + findings := applycheck.ValidateRefs(refs, applycheck.HostSnapshot{Links: []string{"eth0"}}) + + var found bool + for i := range findings { + f := &findings[i] + if f.IsBlocker() && strings.Contains(f.Ref.Name, "bond9") { + found = true + } + } + + if !found { + t.Errorf("a parent that is neither on the node nor created by this config must still block, got %+v", findings) + } +} + +// WireguardConfig is deliberately the exception to the created-link union: +// it belongs to the net-addr walker (which validates peers[].endpoint) and +// stays out of the link dispatch map, so it does NOT register its .name as +// a created link. A VLAN or VIP layered on a wireguard link therefore still +// blocks on first apply. This pins that boundary so the README claim and +// the code cannot drift. +func TestValidateRefs_WireguardCreatedLinkDoesNotSatisfyReferences(t *testing.T) { + t.Parallel() + + rendered := []byte(`apiVersion: v1alpha1 +kind: WireguardConfig +name: wg0 +--- +apiVersion: v1alpha1 +kind: VLANConfig +name: wg0.7 +vlanID: 7 +parent: wg0 +--- +apiVersion: v1alpha1 +kind: Layer2VIPConfig +name: "192.0.2.254" +link: wg0 +`) + + refs, err := applycheck.WalkRefs(rendered) + if err != nil { + t.Fatalf("WalkRefs: %v", err) + } + + findings := applycheck.ValidateRefs(refs, applycheck.HostSnapshot{Links: []string{"eth0"}}) + + var parentBlocked, linkBlocked bool + for i := range findings { + f := &findings[i] + if !f.IsBlocker() || !strings.Contains(f.Ref.Name, "wg0") { + continue + } + if strings.HasSuffix(f.Ref.Source, ".parent") { + parentBlocked = true + } + if strings.HasSuffix(f.Ref.Source, ".link") { + linkBlocked = true + } + } + if !parentBlocked || !linkBlocked { + t.Errorf("a VLAN parent and a VIP link on a wireguard-created link must both still block, got %+v", findings) + } +} + +// TestValidateRefs_LinkAliasPatternAdmitsExpansions pins that a +// LinkAliasConfig whose name carries a trailing %d verb does not block +// references to the aliases it produces. Talos expands such a name into +// one sequential alias per matched link (net0, net1, ...), so the +// literal name is the one thing that never exists on the node while the +// expansions are exactly what other documents point at. +func TestValidateRefs_LinkAliasPatternAdmitsExpansions(t *testing.T) { + t.Parallel() + + refs := []applycheck.Ref{ + {Kind: applycheck.RefKindLinkCreated, Name: "net%d", Source: "doc[0].name"}, + {Kind: applycheck.RefKindLink, Name: "net0", Source: "doc[1].parent"}, + {Kind: applycheck.RefKindLink, Name: "net11", Source: "doc[2].link"}, + } + + findings := applycheck.ValidateRefs(refs, applycheck.HostSnapshot{Links: []string{"eth0"}}) + for i := range findings { + if findings[i].IsBlocker() { + t.Errorf("an alias expansion must not block, got %+v", findings[i]) + } + } +} + +// TestValidateRefs_LinkAliasPatternStillBlocksNonMatching pins the other +// half: a pattern admits its own expansions, not every name that happens +// to start with the prefix. +func TestValidateRefs_LinkAliasPatternStillBlocksNonMatching(t *testing.T) { + t.Parallel() + + refs := []applycheck.Ref{ + {Kind: applycheck.RefKindLinkCreated, Name: "net%d", Source: "doc[0].name"}, + {Kind: applycheck.RefKindLink, Name: "netfoo", Source: "doc[1].parent"}, + {Kind: applycheck.RefKindLink, Name: "net", Source: "doc[2].link"}, + } + + blocked := map[string]bool{} + + findings := applycheck.ValidateRefs(refs, applycheck.HostSnapshot{Links: []string{"eth0"}}) + for i := range findings { + if findings[i].IsBlocker() { + blocked[findings[i].Ref.Name] = true + } + } + + for _, name := range []string{"netfoo", "net"} { + if !blocked[name] { + t.Errorf("%q is not an expansion of net%%d and must still block, got %+v", name, findings) + } + } +} + +// TestValidateRefs_LinkAliasFixedNameUnchanged pins that a fixed alias +// name keeps its existing behaviour: it is a link the config creates, so +// references to it resolve. +func TestValidateRefs_LinkAliasFixedNameUnchanged(t *testing.T) { + t.Parallel() + + refs := []applycheck.Ref{ + {Kind: applycheck.RefKindLinkCreated, Name: "private", Source: "doc[0].name"}, + {Kind: applycheck.RefKindLink, Name: "private", Source: "doc[1].parent"}, + } + + findings := applycheck.ValidateRefs(refs, applycheck.HostSnapshot{Links: []string{"eth0"}}) + for i := range findings { + if findings[i].IsBlocker() { + t.Errorf("a reference to a created alias must resolve, got %+v", findings[i]) + } + } +} diff --git a/pkg/engine/contract_cluster_test.go b/pkg/engine/contract_cluster_test.go index d7fb059..6ce9815 100644 --- a/pkg/engine/contract_cluster_test.go +++ b/pkg/engine/contract_cluster_test.go @@ -34,6 +34,7 @@ package engine import ( + "strings" "testing" ) @@ -509,3 +510,235 @@ func TestContract_Cluster_GenericCertSANsAppendsVerbatim(t *testing.T) { assertContains(t, out, "- api.example.com") assertNotContains(t, out, "- 127.0.0.1") } + +// Contract: extraApiServerArgs / extraControllerManagerArgs / +// extraSchedulerArgs merge into the respective control-plane component +// extraArgs on cozystack, on top of the preset's built-in args. +func TestContract_Cluster_ExtraControlPlaneArgs_Merge_Cozystack(t *testing.T) { + out := renderCozystackWith(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "extraApiServerArgs": map[string]any{"max-requests-inflight": "2000"}, + "extraControllerManagerArgs": map[string]any{"concurrent-gc-syncs": "30"}, + "extraSchedulerArgs": map[string]any{"kube-api-qps": "100"}, + }) + assertContains(t, out, "max-requests-inflight:") + assertContains(t, out, "concurrent-gc-syncs:") + assertContains(t, out, "kube-api-qps:") + assertContains(t, out, "bind-address: 0.0.0.0") +} + +// Contract: extraApiServerArgs emits apiServer.extraArgs even when +// oidcIssuerUrl is unset — the block is hoisted out of the OIDC guard. +func TestContract_Cluster_ExtraApiServerArgs_WithoutOIDC_Cozystack(t *testing.T) { + out := renderCozystackWith(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "extraApiServerArgs": map[string]any{"max-requests-inflight": "2000"}, + }) + assertContains(t, out, "max-requests-inflight:") + assertNotContains(t, out, "oidc-issuer-url") +} + +// Contract: extraControllerManagerArgs colliding with a built-in +// (bind-address) fails the render with a hinted message. +func TestContract_Cluster_ExtraControllerManagerArgs_Collision_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "extraControllerManagerArgs": map[string]any{"bind-address": "127.0.0.1"}, + }) + if err == nil { + t.Fatal("expected a collision error for extraControllerManagerArgs.bind-address") + } + if !strings.Contains(err.Error(), "bind-address") { + t.Errorf("error should name the colliding key, got %v", err) + } +} + +// Contract: extraSchedulerArgs colliding with the preset's +// built-in scheduler bind-address fails the render with a hinted message. +func TestContract_Cluster_ExtraSchedulerArgs_Collision_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "extraSchedulerArgs": map[string]any{"bind-address": "127.0.0.1"}, + }) + if err == nil { + t.Fatal("expected a collision error for extraSchedulerArgs.bind-address") + } + if !strings.Contains(err.Error(), "bind-address") { + t.Errorf("error should name the colliding key, got %v", err) + } +} + +// Contract: extraApiServerArgs colliding with an oidc-* key while +// oidcIssuerUrl is set fails the render. When OIDC is unset the same key +// is the operator's to own and does not collide. +func TestContract_Cluster_ExtraApiServerArgs_OIDCCollision_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "oidcIssuerUrl": "https://oidc.example.com", + "extraApiServerArgs": map[string]any{"oidc-client-id": "custom"}, + }) + if err == nil { + t.Fatal("expected a collision error for extraApiServerArgs.oidc-client-id when OIDC is set") + } + if !strings.Contains(err.Error(), "oidc-client-id") { + t.Errorf("error should name the colliding key, got %v", err) + } +} + +// Contract: with allocateNodeCIDRs off the preset emits no +// cluster-cidr, so an operator may set cluster-cidr via +// extraControllerManagerArgs without tripping the collision guard. +func TestContract_Cluster_ExtraControllerManagerArgs_ClusterCIDRAllowedWhenAllocateOff_Cozystack(t *testing.T) { + out := renderCozystackWith(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "allocateNodeCIDRs": false, + "extraControllerManagerArgs": map[string]any{"cluster-cidr": "10.0.0.0/8"}, + }) + // extraArgs values are coerced to quoted strings (Talos map[string]string). + assertContains(t, out, `cluster-cidr: "10.0.0.0/8"`) +} + +// Contract: with allocateNodeCIDRs on (the default) cluster-cidr +// is preset-owned and an override still collides. +func TestContract_Cluster_ExtraControllerManagerArgs_ClusterCIDRCollidesWhenAllocateOn_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "extraControllerManagerArgs": map[string]any{"cluster-cidr": "10.0.0.0/8"}, + }) + if err == nil { + t.Fatal("expected a collision for cluster-cidr while allocateNodeCIDRs is on") + } + if !strings.Contains(err.Error(), "cluster-cidr") { + t.Errorf("error should name the colliding key, got %v", err) + } +} + +// Contract: generic exposes the same three passthrough knobs for +// values-file portability, with no preset built-ins to collide with. +func TestContract_Cluster_ExtraControlPlaneArgs_Generic(t *testing.T) { + out := renderGenericWith(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "extraApiServerArgs": map[string]any{"max-requests-inflight": "2000"}, + "extraSchedulerArgs": map[string]any{"kube-api-qps": "100"}, + }) + assertContains(t, out, "max-requests-inflight:") + assertContains(t, out, "kube-api-qps:") +} + +// Contract: Talos component extraArgs is map[string]string, so an +// unquoted numeric value must render as a quoted string, not a bare YAML +// int that Talos rejects on load. +func TestContract_Cluster_ExtraApiServerArgs_NumericCoercedToString_Cozystack(t *testing.T) { + out := renderCozystackWith(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "extraApiServerArgs": map[string]any{"max-requests-inflight": 2000}, + }) + assertContains(t, out, `max-requests-inflight: "2000"`) +} + +// Contract: the generic preset carries its own extraArgs emission, +// so pin the same numeric-to-quoted-string coercion on it. +func TestContract_Cluster_ExtraArgs_NumericCoercedToString_Generic(t *testing.T) { + out := renderGenericWith(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "extraApiServerArgs": map[string]any{"max-requests-inflight": 2000}, + "extraSchedulerArgs": map[string]any{"kube-api-qps": 100}, + }) + assertContains(t, out, `max-requests-inflight: "2000"`) + assertContains(t, out, `kube-api-qps: "100"`) +} + +// Contract: a nil extraArgs value (the `key:` with nothing after it typo) +// must fail rather than render the literal string "" straight onto a +// control-plane component's command line. +func TestContract_Cluster_ExtraArgs_NilValue_Fails_Cozystack(t *testing.T) { + for _, knob := range []string{"extraApiServerArgs", "extraControllerManagerArgs", "extraSchedulerArgs"} { + t.Run(knob, func(t *testing.T) { + err := renderCozystackExpectError(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + knob: map[string]any{"some-flag": nil}, + }) + if err == nil { + t.Fatalf("expected a fail-fast for a nil %s value", knob) + } + if !strings.Contains(err.Error(), "some-flag") { + t.Errorf("error should name the offending key, got %v", err) + } + }) + } +} + +func TestContract_Cluster_ExtraArgs_NilValue_Fails_Generic(t *testing.T) { + err := renderGenericExpectError(t, helmEngineEmptyLookup, map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "extraApiServerArgs": map[string]any{"some-flag": nil}, + }) + if err == nil { + t.Fatal("expected a fail-fast for a nil extraApiServerArgs value on generic") + } + if !strings.Contains(err.Error(), "some-flag") { + t.Errorf("error should name the offending key, got %v", err) + } +} + +// Contract: a component flag takes a single scalar value. The nil check +// alone let a nested map through, and toString put the Go rendering of +// the value (map[a:b]) onto the component command line. +func TestContract_Cluster_ExtraArgsNonScalar_Fails_Cozystack(t *testing.T) { + fields := []string{"extraApiServerArgs", "extraControllerManagerArgs", "extraSchedulerArgs"} + values := map[string]any{ + "map": map[string]any{"nested": "value"}, + "list": []any{"a", "b"}, + } + + for _, field := range fields { + for shape, value := range values { + t.Run(field+"/"+shape, func(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + field: map[string]any{"some-flag": value}, + }) + if err == nil { + t.Fatalf("expected a fail-fast for a %s value under %s", shape, field) + } + + if !strings.Contains(err.Error(), "some-flag") { + t.Errorf("error should name the offending key, got %v", err) + } + }) + } + } +} + +// Contract: the generic preset shares the emission, so it shares the guard. +func TestContract_Cluster_ExtraArgsNonScalar_Fails_Generic(t *testing.T) { + err := renderGenericExpectError(t, simpleNicLookup(), map[string]any{ + "extraApiServerArgs": map[string]any{"some-flag": map[string]any{"nested": "value"}}, + }) + if err == nil { + t.Fatal("expected a fail-fast for a non-scalar extraApiServerArgs value") + } + + if !strings.Contains(err.Error(), "some-flag") { + t.Errorf("error should name the offending key, got %v", err) + } +} + +// Contract: machine.time.servers is a list of NTP hosts. A mapping there +// renders as a mapping and the node rejects it. +func TestContract_Cluster_TimeServersMapping_Fails(t *testing.T) { + for _, render := range []func(*testing.T, func(string, string, string) (map[string]any, error), map[string]any, ...string) error{ + renderCozystackExpectError, + renderGenericExpectError, + } { + err := render(t, simpleNicLookup(), map[string]any{ + "timeServers": map[string]any{"primary": "time.cloudflare.com"}, + }) + if err == nil { + t.Fatal("expected a fail-fast for a timeServers mapping") + } + + if !strings.Contains(err.Error(), "timeServers") { + t.Errorf("error should name the field, got %v", err) + } + } +} diff --git a/pkg/engine/contract_errors_test.go b/pkg/engine/contract_errors_test.go index cd83574..1153a24 100644 --- a/pkg/engine/contract_errors_test.go +++ b/pkg/engine/contract_errors_test.go @@ -174,6 +174,52 @@ func legacyInterfacesLookup() func(string, string, string) (map[string]any, erro } } +// legacyMultiInterfaceLookup models a node re-applied on the legacy +// schema: its running MachineConfig carries two interfaces (eth0, eth1) +// that the legacy renderer emits verbatim, and the default route leaves +// via eth0. Used to exercise the vips / vipLink guards against a link +// already present in that preserved block. +func legacyMultiInterfaceLookup() func(string, string, string) (map[string]any, error) { + machineconfig := map[string]any{ + "spec": map[string]any{ + "machine": map[string]any{ + "network": map[string]any{ + "interfaces": []any{ + map[string]any{"interface": "eth0", "addresses": []any{"192.168.1.10/24"}}, + map[string]any{"interface": "eth1", "addresses": []any{"10.0.0.5/24"}}, + }, + }, + }, + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.1.1", + "outLinkName": "eth0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + return func(resource, _, id string) (map[string]any, error) { + switch resource { + case "machineconfig": + if id == "v1alpha1" { + return machineconfig, nil + } + case "routes": + return routesList, nil + } + return map[string]any{}, nil + } +} + // Contract: multi-doc renderer aborts if the running MachineConfig // already carries machine.network.interfaces[]. The fail message // explains both the why (renderer cannot translate legacy block to @@ -387,3 +433,29 @@ func TestContract_Errors_MultidocVLANMissingVlanID(t *testing.T) { }) } } + +// legacyDeviceSelectorLookup models a running MachineConfig whose +// interfaces block selects its device by matcher instead of by name, so +// the device name cannot be resolved at render time. +func legacyDeviceSelectorLookup() func(string, string, string) (map[string]any, error) { + machineconfig := map[string]any{ + "spec": map[string]any{ + "machine": map[string]any{ + "network": map[string]any{ + "interfaces": []any{ + map[string]any{ + "deviceSelector": map[string]any{"busPath": "0000:01:00.0"}, + "addresses": []any{"192.168.1.10/24"}, + }, + }, + }, + }, + }, + } + return func(resource, _, id string) (map[string]any, error) { + if resource == "machineconfig" && id == "v1alpha1" { + return machineconfig, nil + } + return map[string]any{}, nil + } +} diff --git a/pkg/engine/contract_machine_test.go b/pkg/engine/contract_machine_test.go index 580ff8c..29f3187 100644 --- a/pkg/engine/contract_machine_test.go +++ b/pkg/engine/contract_machine_test.go @@ -28,6 +28,7 @@ package engine import ( + "encoding/base64" "strings" "testing" @@ -894,3 +895,448 @@ func TestContract_Machine_NoRegistriesOnGeneric(t *testing.T) { }) } } + +// Contract: timeServers values render machine.time.servers on +// both charts, letting a node on an isolated network point at a local +// NTP source without hand-editing the generated config. +func TestContract_Machine_TimeServers_RenderWhenSet(t *testing.T) { + t.Run("cozystack", func(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "timeServers": []any{"192.0.2.1", "192.0.2.2"}, + }) + assertContains(t, out, "time:") + assertContains(t, out, "servers:") + assertContains(t, out, "- 192.0.2.1") + assertContains(t, out, "- 192.0.2.2") + }) + t.Run("generic", func(t *testing.T) { + out := renderGenericWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "timeServers": []any{"192.0.2.1"}, + }) + assertContains(t, out, "time:") + assertContains(t, out, "- 192.0.2.1") + }) +} + +// Contract: no machine.time block when timeServers is unset (the +// default). The "\n time:" anchor is the 2-space-indented machine.time +// key, so it does not collide with the tcp_keepalive_time sysctl. +func TestContract_Machine_TimeServers_EmptyByDefault(t *testing.T) { + t.Run("cozystack", func(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + }) + assertNotContains(t, out, "\n time:") + }) + t.Run("generic", func(t *testing.T) { + out := renderGenericWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + }) + assertNotContains(t, out, "\n time:") + }) +} + +// Contract: registryMirrors drives both the legacy +// registries.mirrors block and the multidoc RegistryMirrorConfig +// documents. The docker.io → mirror.gcr.io default is preserved and +// additional mirrors (e.g. ghcr.io) are added, so an operator mirroring +// more registries no longer has to fork the preset. +func TestContract_Machine_RegistryMirrors_CustomMirror_Cozystack(t *testing.T) { + mirrors := map[string]any{ + "docker.io": map[string]any{"endpoints": []any{"https://mirror.gcr.io"}}, + "ghcr.io": map[string]any{"endpoints": []any{"https://registry.example.com/v2/ghcr.io"}}, + } + + t.Run("multidoc", func(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": mirrors, + }) + assertContains(t, out, "kind: RegistryMirrorConfig") + assertContains(t, out, "name: ghcr.io") + assertContains(t, out, "https://registry.example.com/v2/ghcr.io") + assertContains(t, out, "name: docker.io") + }) + + t.Run("legacy", func(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": mirrors, + }) + assertContains(t, out, "ghcr.io:") + assertContains(t, out, "- https://registry.example.com/v2/ghcr.io") + assertContains(t, out, "docker.io:") + }) +} + +// Contract: the working opt-out is nulling the key +// (registryMirrors: nil), which emits no registry config on cozystack — +// neither the legacy registries block nor a RegistryMirrorConfig. This is +// faithful to production: the engine deep-merges per-node overrides, and a +// nil override stays nil (whereas {} would merge-fill the default +// docker.io mirror back in, so {} is NOT an opt-out). +func TestContract_Machine_RegistryMirrors_NullClears_Cozystack(t *testing.T) { + t.Run("multidoc", func(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": nil, + }) + assertNotContains(t, out, "kind: RegistryMirrorConfig") + }) + + t.Run("legacy", func(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": nil, + }) + assertNotContains(t, out, "registries:") + }) +} + +// Contract: a registryMirrors entry with no endpoints fails fast +// rather than emitting endpoints: null, matching the bond / vlan guards. +func TestContract_Machine_RegistryMirrors_NoEndpoints_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": map[string]any{"ghcr.io": map[string]any{}}, + }) + if err == nil { + t.Fatal("expected a fail-fast for a registryMirrors entry with no endpoints") + } + if !strings.Contains(err.Error(), "endpoints") { + t.Errorf("error should explain the missing endpoints, got %v", err) + } +} + +// Contract: a nil-valued registryMirrors key (`ghcr.io:` with +// nothing after it in YAML) fails fast with a precise message instead of +// panicking with an opaque "nil pointer evaluating interface {}.endpoints" +// Go-template error. +func TestContract_Machine_RegistryMirrors_NilValue_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": map[string]any{"ghcr.io": nil}, + }) + if err == nil { + t.Fatal("expected a fail-fast for a nil-valued registryMirrors key") + } + if strings.Contains(err.Error(), "nil pointer evaluating") { + t.Errorf("error should be the friendly mapping message, not the opaque nil-pointer panic, got %v", err) + } + if !strings.Contains(err.Error(), "must be a mapping") { + t.Errorf("error should explain the value must be a mapping, got %v", err) + } +} + +// Contract: registryTLS pins TLS behaviour per registry endpoint host. On +// the multi-doc schema each host becomes its own RegistryTLSConfig +// document. The key is the endpoint host, not the mirror name, so a +// self-signed pull-through mirror can be trusted without touching the +// mirror list. +func TestContract_Machine_RegistryTLS_Multidoc_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "mirror.example.invalid": map[string]any{"insecureSkipVerify": true}, + }, + }) + assertContains(t, out, "kind: RegistryTLSConfig") + assertContains(t, out, "name: mirror.example.invalid") + assertContains(t, out, "insecureSkipVerify: true") +} + +// Contract: on the legacy schema the same knob lands in +// machine.registries.config..tls, which is where pre-1.12 Talos +// reads it from. +func TestContract_Machine_RegistryTLS_Legacy_Cozystack(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "mirror.example.invalid": map[string]any{"insecureSkipVerify": true}, + }, + }) + assertContains(t, out, "config:") + assertContains(t, out, "mirror.example.invalid:") + assertContains(t, out, "tls:") + assertContains(t, out, "insecureSkipVerify: true") +} + +// Contract: a registryTLS entry must state insecureSkipVerify explicitly. +// Silently defaulting it would decide a security posture for the operator. +func TestContract_Machine_RegistryTLS_MissingFlag_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{"mirror.example.invalid": map[string]any{}}, + }) + if err == nil { + t.Fatal("expected a fail-fast for a registryTLS entry with no insecureSkipVerify") + } + if !strings.Contains(err.Error(), "insecureSkipVerify") { + t.Errorf("error should name the missing key, got %v", err) + } +} + +// Contract: a nil-valued registryTLS key fails with the mapping message +// rather than an opaque nil-pointer template panic. +func TestContract_Machine_RegistryTLS_NilValue_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{"mirror.example.invalid": nil}, + }) + if err == nil { + t.Fatal("expected a fail-fast for a nil-valued registryTLS key") + } + if strings.Contains(err.Error(), "nil pointer evaluating") { + t.Errorf("error should be the friendly mapping message, not a nil-pointer panic, got %v", err) + } + if !strings.Contains(err.Error(), "must be a mapping") { + t.Errorf("error should explain the value must be a mapping, got %v", err) + } +} + +// Contract: insecureSkipVerify false is emitted verbatim — the knob can +// pin verification ON, not only off. +func TestContract_Machine_RegistryTLS_FalseIsEmitted_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "mirror.example.invalid": map[string]any{"insecureSkipVerify": false}, + }, + }) + assertContains(t, out, "kind: RegistryTLSConfig") + assertContains(t, out, "insecureSkipVerify: false") +} + +// testRegistryCAPEM is a syntactically-shaped PEM block; the render never +// parses it (Talos validates on load), so a placeholder body is enough. +const testRegistryCAPEM = "-----BEGIN CERTIFICATE-----\nMIIBplaceholder\n-----END CERTIFICATE-----" + +// Contract: registryTLS.ca is supplied as PEM — the form an operator has +// on disk — and the chart adapts it per schema. The multi-doc +// RegistryTLSConfig takes PEM verbatim. +func TestContract_Machine_RegistryTLS_CA_Multidoc_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "mirror.example.invalid": map[string]any{"ca": testRegistryCAPEM}, + }, + }) + assertContains(t, out, "kind: RegistryTLSConfig") + assertContains(t, out, "ca: |-") + assertContains(t, out, "-----BEGIN CERTIFICATE-----") + assertContains(t, out, "-----END CERTIFICATE-----") +} + +// Contract: the legacy machine.registries.config..tls.ca field is +// Base64Bytes — it base64-decodes on load — so the same PEM must be +// emitted base64-encoded there, not verbatim. +func TestContract_Machine_RegistryTLS_CA_Legacy_Cozystack(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "mirror.example.invalid": map[string]any{"ca": testRegistryCAPEM}, + }, + }) + assertContains(t, out, "ca: "+base64.StdEncoding.EncodeToString([]byte(testRegistryCAPEM))) + // The raw PEM must not leak into the legacy field, or Talos fails to decode it. + assertNotContains(t, out, "-----BEGIN CERTIFICATE-----") +} + +// Contract: supplying a ca alone is a complete, secure entry — it does not +// additionally require insecureSkipVerify to be spelled out. +func TestContract_Machine_RegistryTLS_CAOnly_NoFlagNeeded_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "mirror.example.invalid": map[string]any{"ca": testRegistryCAPEM}, + }, + }) + assertContains(t, out, "kind: RegistryTLSConfig") + assertNotContains(t, out, "insecureSkipVerify") +} + +// Contract: a scalar endpoints value (the missing-dash typo) fails with a +// precise message rather than a raw "range can't iterate" template error. +func TestContract_Machine_RegistryMirrors_ScalarEndpoints_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": map[string]any{ + "ghcr.io": map[string]any{"endpoints": "https://mirror.example.invalid"}, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a scalar registryMirrors endpoints value") + } + if strings.Contains(err.Error(), "range can't iterate") { + t.Errorf("error should be the friendly list message, not a raw template error, got %v", err) + } + if !strings.Contains(err.Error(), "endpoints") { + t.Errorf("error should name the offending key, got %v", err) + } +} + +// Contract: a non-string ca fails with a precise message rather than a raw +// "wrong type for value" template error from trim. +func TestContract_Machine_RegistryTLS_NonStringCA_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{"mirror.example.invalid": map[string]any{"ca": 42}}, + }) + if err == nil { + t.Fatal("expected a fail-fast for a non-string registryTLS ca") + } + if strings.Contains(err.Error(), "wrong type for value") { + t.Errorf("error should be the friendly message, not a raw template error, got %v", err) + } + if !strings.Contains(err.Error(), "PEM") { + t.Errorf("error should explain the expected PEM form, got %v", err) + } +} + +// Contract: registry knobs work on generic too. The generic preset ships +// no opinionated default, but a values file must stay portable between +// presets rather than silently losing its registry configuration. +func TestContract_Machine_RegistryMirrors_Generic(t *testing.T) { + out := renderGenericWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": map[string]any{ + "ghcr.io": map[string]any{"endpoints": []any{"https://mirror.example.invalid"}}, + }, + "registryTLS": map[string]any{ + "mirror.example.invalid": map[string]any{"insecureSkipVerify": true}, + }, + }) + assertContains(t, out, "kind: RegistryMirrorConfig") + assertContains(t, out, "name: ghcr.io") + assertContains(t, out, "kind: RegistryTLSConfig") +} + +// Contract: an endpoint URL without a scheme is rejected by Talos +// ("unsupported scheme"), so it fails at render. +func TestContract_Machine_RegistryMirrors_SchemelessEndpoint_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": map[string]any{ + "ghcr.io": map[string]any{"endpoints": []any{"mirror.example.invalid"}}, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a schemeless mirror endpoint") + } + if !strings.Contains(err.Error(), "scheme") { + t.Errorf("error should explain the missing scheme, got %v", err) + } +} + +// Contract: insecureSkipVerify decides a security posture, so a non-bool +// (e.g. the YAML-ambiguous "off") is refused rather than emitted verbatim. +func TestContract_Machine_RegistryTLS_NonBoolFlag_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "mirror.example.invalid": map[string]any{"insecureSkipVerify": "off"}, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a non-bool insecureSkipVerify") + } + if !strings.Contains(err.Error(), "insecureSkipVerify") { + t.Errorf("error should name the offending key, got %v", err) + } +} + +// Contract: Talos documents "*" as a first-class fallback mirror name. It +// is also a YAML alias indicator, so it must be emitted quoted or the +// rendered config no longer parses. Pinned by decoding, not by substring. +func TestContract_Machine_RegistryMirrors_FallbackKey_Multidoc_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": map[string]any{ + "*": map[string]any{"endpoints": []any{"https://cache.example.invalid"}}, + }, + }) + for _, doc := range strings.Split(out, "\n---\n") { + var v any + if err := yaml.Unmarshal([]byte(doc), &v); err != nil { + t.Fatalf("rendered document must be valid YAML, got %v in:\n%s", err, doc) + } + } + assertContains(t, out, "kind: RegistryMirrorConfig") +} + +func TestContract_Machine_RegistryMirrors_FallbackKey_Legacy_Cozystack(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryMirrors": map[string]any{ + "*": map[string]any{"endpoints": []any{"https://cache.example.invalid"}}, + }, + }) + var v any + if err := yaml.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("legacy render with a fallback mirror must be valid YAML, got %v", err) + } +} + +// Contract: Talos states the fallback key cannot be used for TLS config, +// so the chart refuses it instead of emitting a document Talos ignores. +func TestContract_Machine_RegistryTLS_FallbackKey_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{"*": map[string]any{"insecureSkipVerify": true}}, + }) + if err == nil { + t.Fatal("expected a fail-fast for a registryTLS fallback key") + } + if !strings.Contains(err.Error(), "*") { + t.Errorf("error should name the offending key, got %v", err) + } +} + +// Contract: registryTLS is keyed by the endpoint host, and an endpoint on +// an IPv6 literal carries the bracketed host:port form Talos itself +// documents. `[` opens a YAML flow sequence, so the key has to be emitted +// quoted on both schemas — pinned by decoding the render, not by matching +// a substring. +func TestContract_Machine_RegistryTLS_IPv6EndpointHost_Multidoc_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "[2001:db8::1]:5000": map[string]any{"insecureSkipVerify": true}, + }, + }) + for _, doc := range strings.Split(out, "\n---\n") { + var v any + if err := yaml.Unmarshal([]byte(doc), &v); err != nil { + t.Fatalf("rendered document must be valid YAML, got %v in:\n%s", err, doc) + } + } + assertContains(t, out, "kind: RegistryTLSConfig") +} + +func TestContract_Machine_RegistryTLS_IPv6EndpointHost_Legacy_Cozystack(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "[2001:db8::1]:5000": map[string]any{"insecureSkipVerify": true}, + }, + }) + var v any + if err := yaml.Unmarshal([]byte(out), &v); err != nil { + t.Fatalf("legacy render with an IPv6 endpoint host must be valid YAML, got %v", err) + } +} + +// Contract: the ordinary host:port form stays bare — toYaml quotes only +// where YAML requires it, so the readable output operators are used to +// does not regress into everything-quoted. +func TestContract_Machine_RegistryTLS_PlainHostStaysBare_Multidoc_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "registryTLS": map[string]any{ + "mirror.example.invalid:5000": map[string]any{"insecureSkipVerify": true}, + }, + }) + assertContains(t, out, "name: mirror.example.invalid:5000") +} diff --git a/pkg/engine/contract_network_legacy_test.go b/pkg/engine/contract_network_legacy_test.go index aad3018..68e0724 100644 --- a/pkg/engine/contract_network_legacy_test.go +++ b/pkg/engine/contract_network_legacy_test.go @@ -26,6 +26,8 @@ package engine import ( "strings" "testing" + + "gopkg.in/yaml.v3" ) // renderLegacyCozystackControlplane renders the cozystack controlplane @@ -240,3 +242,314 @@ func TestContract_NetworkLegacy_ExistingInterfacesShortCircuit(t *testing.T) { assertContains(t, out, "interface: eth0") assertContains(t, out, "192.168.1.10/24") } + +// Contract: the legacy schema emits one interface entry with an +// inline vip per vips entry (no Layer2VIPConfig document pre-1.12). Links +// are non-primary (simpleNicLookup's primary is eth0) so they don't hit +// the primary-collision guard exercised below. +func TestContract_NetworkLegacy_MultiVIP_Cozystack(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth1", "ip": "192.0.2.254"}, + map[string]any{"link": "eth2", "ip": "203.0.113.254"}, + }, + }) + assertContains(t, out, "interface: eth1") + assertContains(t, out, "interface: eth2") + assertContains(t, out, "ip: 192.0.2.254") + assertContains(t, out, "ip: 203.0.113.254") +} + +// Contract: a vips entry whose link is the discovered primary link +// fails fast on legacy — the primary already has an interfaces[] entry, so +// a second one would double-pin the device (Talos won't merge them). +func TestContract_NetworkLegacy_MultiVIP_LinkEqualsPrimary_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth0", "ip": "192.0.2.254"}, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected a fail-fast for a vips link equal to the primary link on the legacy schema") + } + if !strings.Contains(err.Error(), "primary link") { + t.Errorf("error should explain the primary-link collision, got %v", err) + } +} + +// Contract: two vips entries on the same link fail fast on legacy — +// interfaces[].vip holds a single IP per interface, so they cannot both be +// expressed. +func TestContract_NetworkLegacy_MultiVIP_DuplicateLink_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth1", "ip": "192.0.2.254"}, + map[string]any{"link": "eth1", "ip": "192.0.2.253"}, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected a fail-fast for two vips entries on the same legacy link") + } + if !strings.Contains(err.Error(), "already carries a VIP") { + t.Errorf("error should explain the duplicate link, got %v", err) + } +} + +// Contract: a vips entry on the same link as the vipLink override +// fails fast on legacy — the override already pins a vip there. +func TestContract_NetworkLegacy_MultiVIP_LinkEqualsVipLink_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "floatingIP": "192.0.2.99", + "vipLink": "eth1", + "vips": []any{ + map[string]any{"link": "eth1", "ip": "192.0.2.50"}, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected a fail-fast for a vips link equal to the vipLink override on legacy") + } + if !strings.Contains(err.Error(), "already carries a VIP") { + t.Errorf("error should explain the link already carries a VIP, got %v", err) + } +} + +// Contract: a vips ip equal to floatingIP fails fast on legacy — +// the same VIP ip on two links loses arbitration on apply. +func TestContract_NetworkLegacy_MultiVIP_DuplicateIP_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "floatingIP": "192.0.2.99", + "vipLink": "eth1", + "vips": []any{ + map[string]any{"link": "eth2", "ip": "192.0.2.99"}, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected a fail-fast for a vips ip duplicating floatingIP on legacy") + } + if !strings.Contains(err.Error(), "more than once") { + t.Errorf("error should explain the duplicate ip, got %v", err) + } +} + +// Contract: the legacy duplicate-ip check compares addresses, not +// strings, so one IPv6 VIP spelled two ways is still caught. Legacy +// pins at most one inline vip per interface, so a duplicate that slips +// through ships two interfaces claiming the same address. +func TestContract_NetworkLegacy_MultiVIP_DuplicateIPAcrossSpellings_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "floatingIP": "2001:0DB8::99", + "vipLink": "eth1", + "vips": []any{ + map[string]any{"link": "eth2", "ip": "2001:db8::99"}, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected a fail-fast for one VIP declared under two spellings on legacy") + } + + if !strings.Contains(err.Error(), "more than once") { + t.Errorf("error should explain the duplicate ip, got %v", err) + } +} + +// Contract: a malformed vips[].ip fails the legacy render too, +// matching the multidoc fail-fast. +func TestContract_NetworkLegacy_MultiVIP_InvalidIP_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth0", "ip": "not-an-ip"}, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected a validation error for a malformed vips[].ip in the legacy schema") + } + if !strings.Contains(err.Error(), "not-an-ip") { + t.Errorf("error should name the malformed ip, got %v", err) + } +} + +// Contract: vips are emitted in the legacy schema even when +// discovery resolves no default-route link — the interfaces block opens +// on vips too, so a declared VIP is not silently dropped. +func TestContract_NetworkLegacy_MultiVIP_NoDefaultLink_Cozystack(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", noDefaultRouteWithSubnetMatchLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth0", "ip": "192.0.2.254"}, + }, + }) + assertContains(t, out, "interfaces:") + assertContains(t, out, "interface: eth0") + assertContains(t, out, "ip: 192.0.2.254") +} + +// Contract: preserveExisting does NOT add a second +// machine.network block on the legacy schema — the legacy renderer +// already emits one, so a duplicate mapping key would make yaml.v3 +// reject the config on load ("mapping key already defined"). The knob's +// machine.common block is multi-doc only. Asserting the document decodes +// cleanly pins exactly the failure the duplicate would cause. +func TestContract_NetworkLegacy_PreserveExisting_NoDuplicateNetwork_Cozystack(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", legacyInterfacesLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{"preserveExisting": true}, + }) + + var doc map[string]any + if err := yaml.Unmarshal([]byte(out), &doc); err != nil { + t.Fatalf("legacy preserveExisting render must be valid YAML (no duplicate machine.network), got: %v\n%s", err, out) + } +} + +// Contract: extraLinks fails fast on the legacy schema (it is +// multi-doc only) rather than silently dropping the declared links. +func TestContract_NetworkLegacy_ExtraLinks_FailsFast_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "bond1", "bond": map[string]any{"interfaces": []any{"eth0"}}}, + }, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected extraLinks to fail fast on the legacy schema") + } + if !strings.Contains(err.Error(), "multi-doc") { + t.Errorf("error should explain the multi-doc-only limitation, got %v", err) + } +} + +// Contract: the generic preset carries its own copy of the legacy +// vips loop, so pin its happy path too — each vips entry becomes one +// interface with an inline vip. Links are non-primary (simpleNicLookup's +// primary is eth0) so they don't hit the primary-collision guard. +func TestContract_NetworkLegacy_MultiVIP_Generic(t *testing.T) { + out := renderLegacyChart(t, genericChartPath, "generic/templates/controlplane.yaml", simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth1", "ip": "192.0.2.254"}, + map[string]any{"link": "eth2", "ip": "203.0.113.254"}, + }, + }) + assertContains(t, out, "interface: eth1") + assertContains(t, out, "interface: eth2") + assertContains(t, out, "ip: 192.0.2.254") + assertContains(t, out, "ip: 203.0.113.254") +} + +// Contract: extraLinks fails fast on the generic legacy schema +// too — the fail-fast is duplicated per preset, so the generic copy needs +// its own guard test. +func TestContract_NetworkLegacy_ExtraLinks_FailsFast_Generic(t *testing.T) { + err := renderGenericExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "bond1", "bond": map[string]any{"interfaces": []any{"eth0"}}}, + }, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected extraLinks to fail fast on the generic legacy schema") + } + if !strings.Contains(err.Error(), "multi-doc") { + t.Errorf("error should explain the multi-doc-only limitation, got %v", err) + } +} + +// Contract: a vips entry whose link is already present in the +// running interfaces block (a re-applied legacy node emits that block +// verbatim) fails fast — a second interfaces[] entry for the same device +// is a duplicate Talos won't merge. legacyMultiInterfaceLookup preserves +// eth0+eth1; the VIP targets eth1. +func TestContract_NetworkLegacy_MultiVIP_LinkInPreservedBlock_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, legacyMultiInterfaceLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth1", "ip": "192.0.2.254"}, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected a fail-fast for a vips link already in the preserved interfaces block") + } + if !strings.Contains(err.Error(), "already present") { + t.Errorf("error should explain the link is already in the preserved block, got %v", err) + } +} + +// Contract: the generic preset carries its own copy of the vips +// loop, so pin the same preserved-link collision on it. +func TestContract_NetworkLegacy_MultiVIP_LinkInPreservedBlock_Fails_Generic(t *testing.T) { + err := renderGenericExpectError(t, legacyMultiInterfaceLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth1", "ip": "192.0.2.254"}, + }, + }, "v1.11") + if err == nil { + t.Fatal("expected a fail-fast for a vips link already in the preserved interfaces block") + } + if !strings.Contains(err.Error(), "already present") { + t.Errorf("error should explain the link is already in the preserved block, got %v", err) + } +} + +// Contract: the vipLink override path has the same hole — a +// vipLink naming a link already in the preserved block would also +// double-declare the device. eth1 is preserved; eth0 is the primary. +func TestContract_NetworkLegacy_VipLinkOverride_InPreservedBlock_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, legacyMultiInterfaceLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "floatingIP": "192.0.2.254", + "vipLink": "eth1", + }, "v1.11") + if err == nil { + t.Fatal("expected a fail-fast for a vipLink override naming a preserved link") + } + if !strings.Contains(err.Error(), "already present") { + t.Errorf("error should explain the vipLink is already in the preserved block, got %v", err) + } +} + +// Contract: generic counterpart of the vipLink-override collision. +func TestContract_NetworkLegacy_VipLinkOverride_InPreservedBlock_Fails_Generic(t *testing.T) { + err := renderGenericExpectError(t, legacyMultiInterfaceLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "floatingIP": "192.0.2.254", + "vipLink": "eth1", + }, "v1.11") + if err == nil { + t.Fatal("expected a fail-fast for a vipLink override naming a preserved link") + } + if !strings.Contains(err.Error(), "already present") { + t.Errorf("error should explain the vipLink is already in the preserved block, got %v", err) + } +} + +// Contract: the preserved-link guard must not over-fire — a vips +// entry on a link that is NOT in the preserved block (a genuinely new +// device) still renders one interface entry alongside the preserved ones. +func TestContract_NetworkLegacy_MultiVIP_NewLinkWithPreservedBlock_Cozystack(t *testing.T) { + out := renderLegacyChart(t, cozystackChartPath, "cozystack/templates/controlplane.yaml", legacyMultiInterfaceLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth2", "ip": "192.0.2.254"}, + }, + }) + // The preserved interfaces survive. + assertContains(t, out, "interface: eth0") + assertContains(t, out, "interface: eth1") + // The new VIP link is emitted exactly once. + if got := strings.Count(out, "interface: eth2"); got != 1 { + t.Errorf("expected exactly one eth2 interface entry, got %d:\n%s", got, out) + } + assertContains(t, out, "ip: 192.0.2.254") +} diff --git a/pkg/engine/contract_network_multidoc_test.go b/pkg/engine/contract_network_multidoc_test.go index 16d44ca..ae0b60d 100644 --- a/pkg/engine/contract_network_multidoc_test.go +++ b/pkg/engine/contract_network_multidoc_test.go @@ -1503,3 +1503,1264 @@ func TestContract_NetworkMultidoc_FloatingIPNotInDiscoveredSubnetFallsBackToGate t.Errorf("expected exactly 1 Layer2VIPConfig (fallback path), got %d:\n%s", got, out) } } + +// Contract: the vips list emits one Layer2VIPConfig per entry, +// each bound to its declared link — a control-plane VIP on the primary +// link and a separate VIP on a storage/secondary link, expressible +// without hand-editing. floatingIP/vipLink remain the single-VIP +// shorthand and are pinned by the tests above. +func TestContract_NetworkMultidoc_MultiVIP_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth0", "ip": "192.0.2.254"}, + map[string]any{"link": "eth1", "ip": "203.0.113.254"}, + }, + }) + if got := strings.Count(out, "kind: Layer2VIPConfig"); got != 2 { + t.Errorf("expected 2 Layer2VIPConfig documents, got %d:\n%s", got, out) + } + assertContains(t, out, `name: "192.0.2.254"`) + assertContains(t, out, "link: eth0") + assertContains(t, out, `name: "203.0.113.254"`) + assertContains(t, out, "link: eth1") +} + +// Contract: a vips entry with a malformed ip fails the render +// with a hinted message, the same fail-fast floatingIP gets. +func TestContract_NetworkMultidoc_MultiVIP_InvalidIP_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth0", "ip": "not-an-ip"}, + }, + }) + if err == nil { + t.Fatal("expected a validation error for a malformed vips[].ip") + } + if !strings.Contains(err.Error(), "not-an-ip") { + t.Errorf("error should name the malformed ip, got %v", err) + } +} + +// Contract: a vips entry with no link fails fast rather than +// emitting a Layer2VIPConfig with a null link. +func TestContract_NetworkMultidoc_MultiVIP_EmptyLink_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"ip": "192.0.2.254"}, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a vips entry with no link") + } + if !strings.Contains(err.Error(), "no link") { + t.Errorf("error should explain the missing link, got %v", err) + } +} + +// Contract: vips are not control-plane-only — a storage VIP on a +// secondary link is emitted on a worker node too. This is what +// distinguishes vips from floatingIP (which is control-plane-only). +func TestContract_NetworkMultidoc_MultiVIP_WorkerRole_Cozystack(t *testing.T) { + out := renderCozystackWorkerWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth1", "ip": "203.0.113.254"}, + }, + }) + assertContains(t, out, "kind: Layer2VIPConfig") + assertContains(t, out, `name: "203.0.113.254"`) + assertContains(t, out, "link: eth1") +} + +// Contract: network.preserveExisting emits the running node's +// machine.network.interfaces verbatim instead of failing (the default +// multidoc guard) or rebuilding only the primary link. The typed +// per-link rebuild is skipped so the two don't conflict. +func TestContract_NetworkMultidoc_PreserveExisting_Cozystack(t *testing.T) { + out := renderCozystackWith(t, legacyInterfacesLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{"preserveExisting": true}, + }) + assertContains(t, out, "interface: eth0") + assertContains(t, out, "192.168.1.10/24") + assertNotContains(t, out, "kind: LinkConfig") +} + +// Contract: preserveExisting on a fresh node with no legacy +// interfaces block falls through to the normal typed rebuild rather than +// emitting a network-less config (there is nothing to preserve). +func TestContract_NetworkMultidoc_PreserveExisting_FreshNode_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{"preserveExisting": true}, + }) + assertContains(t, out, "kind: LinkConfig") + assertContains(t, out, "192.168.201.10/24") +} + +// Contract: preserveExisting carries the running +// interfaces verbatim and skips the typed per-link rebuild — including +// the per-link VIP address-strip. A vips entry still emits its own +// Layer2VIPConfig, so the VIP layers on top of the preserved block rather +// than being folded into a LinkConfig. This is correct when the preserved +// block does not itself declare the VIP; if it inlines the same VIP the +// operator owns that duplicate. Pins the layering so it cannot regress +// silently; the live-node interaction is called out in B10e. +func TestContract_NetworkMultidoc_PreserveExisting_WithVIP_Cozystack(t *testing.T) { + out := renderCozystackWith(t, legacyInterfacesLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{"preserveExisting": true}, + "vips": []any{ + map[string]any{"link": "eth0", "ip": "192.0.2.254"}, + }, + }) + // The running interfaces are preserved verbatim. + assertContains(t, out, "interface: eth0") + assertContains(t, out, "192.168.1.10/24") + // The typed per-link rebuild is skipped. + assertNotContains(t, out, "kind: LinkConfig") + // The vips VIP still layers on as its own Layer2VIPConfig. + if got := strings.Count(out, "kind: Layer2VIPConfig"); got != 1 { + t.Errorf("expected exactly 1 Layer2VIPConfig layered on the preserved block, got %d:\n%s", got, out) + } + assertContains(t, out, `name: "192.0.2.254"`) + assertContains(t, out, "link: eth0") +} + +// Contract: when a VIP ip is also present inside the preserved +// interfaces block, preserveExisting would double-declare it (verbatim + +// Layer2VIPConfig). The render fails fast so the operator drops it from +// one side. legacyInterfacesLookup's eth0 carries 192.168.1.10/24. +func TestContract_NetworkMultidoc_PreserveExisting_VIPInPreservedBlock_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, legacyInterfacesLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{"preserveExisting": true}, + "vips": []any{ + map[string]any{"link": "eth0", "ip": "192.168.1.10"}, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a VIP that also lives in the preserved interfaces block") + } + if !strings.Contains(err.Error(), "declared twice") { + t.Errorf("error should explain the double-declaration, got %v", err) + } +} + +// Contract: the double-declaration guard matches whole ip tokens, +// not bare substrings — a VIP 192.168.1.1 must NOT trip the guard against a +// preserved 192.168.1.10/24, so a legitimate VIP still renders. +func TestContract_NetworkMultidoc_PreserveExisting_VIPSubstringNoFalsePositive_Cozystack(t *testing.T) { + out := renderCozystackWith(t, legacyInterfacesLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{"preserveExisting": true}, + "vips": []any{ + map[string]any{"link": "eth0", "ip": "192.168.1.1"}, + }, + }) + assertContains(t, out, "kind: Layer2VIPConfig") + assertContains(t, out, `name: "192.168.1.1"`) +} + +// Contract: a vips ip equal to floatingIP (or another vips ip) +// fails fast — Talos rejects two Layer2VIPConfig documents with the same +// name (each is named after its ip). +func TestContract_NetworkMultidoc_MultiVIP_DuplicateIP_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "floatingIP": "192.0.2.254", + "vipLink": "eth0", + "vips": []any{ + map[string]any{"link": "eth1", "ip": "192.0.2.254"}, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a vips ip duplicating floatingIP") + } + if !strings.Contains(err.Error(), "more than once") { + t.Errorf("error should explain the duplicate ip, got %v", err) + } +} + +// Contract: with preserveExisting off (the default) and a +// running legacy interfaces block, the multidoc renderer still fails +// fast — the guard is only lifted by the opt-in. +func TestContract_NetworkMultidoc_PreserveExisting_OffStillFails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, legacyInterfacesLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + }) + if err == nil { + t.Fatal("expected the legacy-interfaces guard to fail when preserveExisting is off") + } +} + +// Contract: network.extraLinks emits typed documents (BondConfig, +// LinkConfig, VLANConfig) layered on the discovered topology, so a storage +// bond and extra VLANs that discovery does not reconstruct can be declared +// in git instead of forking the template. +func TestContract_NetworkMultidoc_ExtraLinks_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "bond": map[string]any{"interfaces": []any{"enp3s0", "enp4s0"}, "mode": "802.3ad"}, + "addresses": []any{"203.0.113.10/24"}, + }, + // A vlans-only entry whose interface names the bond declared + // above as its parent — cross-entry parent resolution. + map[string]any{ + "interface": "bond1", + "vlans": []any{map[string]any{"vlanId": 7, "addresses": []any{"198.51.100.10/24"}}}, + }, + }, + }, + }) + // bond1 as a BondConfig with its slaves, mode and address + assertContains(t, out, "kind: BondConfig") + assertContains(t, out, "name: bond1") + assertContains(t, out, "- enp3s0") + assertContains(t, out, "- enp4s0") + assertContains(t, out, "bondMode: 802.3ad") + assertContains(t, out, "203.0.113.10/24") + // bond1.7 as a VLANConfig parented on the bond declared above + assertContains(t, out, "kind: VLANConfig") + assertContains(t, out, "vlanID: 7") + assertContains(t, out, "parent: bond1") + assertContains(t, out, "198.51.100.10/24") + // discovery-reconstructed primary link is still present (extraLinks layer, not replace) + assertContains(t, out, "kind: LinkConfig") +} + +// Contract: a vlans-only entry may parent a VLAN onto a +// discovered link. simpleNicLookup discovers eth0; a VLAN on eth0 is +// valid because the parent link already exists. +func TestContract_NetworkMultidoc_ExtraLinks_VlanOnDiscoveredLink_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth0", + "vlans": []any{map[string]any{"vlanId": 100, "addresses": []any{"198.51.100.10/24"}}}, + }, + }, + }, + }) + assertContains(t, out, "kind: VLANConfig") + assertContains(t, out, "name: eth0.100") + assertContains(t, out, "vlanID: 100") + assertContains(t, out, "parent: eth0") + assertContains(t, out, "198.51.100.10/24") +} + +// Contract: a vlans-only entry whose interface names a link that +// is neither discovered nor declared in extraLinks fails fast — Talos +// rejects a VLANConfig whose parent link does not exist. +func TestContract_NetworkMultidoc_ExtraLinks_VlanDanglingParent_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond0", + "vlans": []any{map[string]any{"vlanId": 7, "addresses": []any{"198.51.100.10/24"}}}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a VLAN parented on a non-existent link") + } + if !strings.Contains(err.Error(), "bond0") { + t.Errorf("error should name the missing parent, got %v", err) + } +} + +// Contract: an extraLinks entry with addresses but neither bond +// nor vlans emits a plain LinkConfig on that interface. +func TestContract_NetworkMultidoc_ExtraLinks_PlainLink_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "eth9", "addresses": []any{"203.0.113.20/24"}}, + }, + }, + }) + assertContains(t, out, "kind: LinkConfig") + assertContains(t, out, "name: eth9") + assertContains(t, out, "203.0.113.20/24") +} + +// Contract: a bond extraLinks entry with no interfaces fails +// fast — Talos rejects a BondConfig with an empty links list, so the +// preset refuses to emit links: null. +func TestContract_NetworkMultidoc_ExtraLinks_EmptyBond_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "bond9", "bond": map[string]any{"mode": "802.3ad"}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a bond extraLinks entry with no interfaces") + } + if !strings.Contains(err.Error(), "bond9") { + t.Errorf("error should name the offending interface, got %v", err) + } +} + +// Contract: a vlans entry with no vlanId fails fast — VLANConfig +// requires vlanID on the wire. +func TestContract_NetworkMultidoc_ExtraLinks_VLANNoVlanId_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "bond0", "vlans": []any{map[string]any{"addresses": []any{"198.51.100.10/24"}}}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a vlans entry with no vlanId") + } + if !strings.Contains(err.Error(), "bond0") { + t.Errorf("error should name the offending interface, got %v", err) + } +} + +// Contract: an extraLinks interface that collides with a +// discovered link fails fast — two documents with the same kind+name are +// rejected by Talos on apply. simpleNicLookup discovers eth0. +func TestContract_NetworkMultidoc_ExtraLinks_CollidesWithDiscovered_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "eth0", "addresses": []any{"203.0.113.10/24"}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for an extraLinks interface colliding with a discovered link") + } + if !strings.Contains(err.Error(), "collides") { + t.Errorf("error should explain the collision, got %v", err) + } +} + +// Contract: two extraLinks entries with the same interface fail +// fast — duplicate document name. +func TestContract_NetworkMultidoc_ExtraLinks_DuplicateInterface_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "bond9", "bond": map[string]any{"interfaces": []any{"a", "b"}, "mode": "802.3ad"}}, + map[string]any{"interface": "bond9", "addresses": []any{"203.0.113.10/24"}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for two extraLinks entries with the same interface") + } + if !strings.Contains(err.Error(), "collides") { + t.Errorf("error should explain the collision, got %v", err) + } +} + +// Contract: the generic preset carries its own copy of the vips +// wiring, so pin the multidoc happy path on it too — a copy-paste +// divergence from the cozystack blocks would otherwise ship untested. +func TestContract_NetworkMultidoc_MultiVIP_Generic(t *testing.T) { + out := renderGenericWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{ + map[string]any{"link": "eth0", "ip": "192.0.2.254"}, + map[string]any{"link": "eth1", "ip": "203.0.113.254"}, + }, + }) + if got := strings.Count(out, "kind: Layer2VIPConfig"); got != 2 { + t.Errorf("expected 2 Layer2VIPConfig documents, got %d:\n%s", got, out) + } + assertContains(t, out, `name: "192.0.2.254"`) + assertContains(t, out, "link: eth0") + assertContains(t, out, `name: "203.0.113.254"`) + assertContains(t, out, "link: eth1") +} + +// Contract: the generic preset carries its own preserveExisting +// gate, so pin the multidoc happy path — the running node's interfaces are +// emitted verbatim and the typed per-link rebuild is skipped. +func TestContract_NetworkMultidoc_PreserveExisting_Generic(t *testing.T) { + out := renderGenericWith(t, legacyInterfacesLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{"preserveExisting": true}, + }) + assertContains(t, out, "interface: eth0") + assertContains(t, out, "192.168.1.10/24") + assertNotContains(t, out, "kind: LinkConfig") +} + +// Contract: an extraLinks bond carries the same tuning knobs the +// discovery-derived BondConfig already emits, so a declaratively-created +// bond is not a downgrade from a discovered one. +func TestContract_NetworkMultidoc_ExtraLinks_BondTuning_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "bond": map[string]any{ + "interfaces": []any{"enp3s0", "enp4s0"}, + "mode": "802.3ad", + "xmitHashPolicy": "layer2+3", + "lacpRate": "slow", + "miimon": 100, + "updelay": 200, + "downdelay": 300, + }, + }, + }, + }, + }) + assertContains(t, out, "bondMode: 802.3ad") + assertContains(t, out, "xmitHashPolicy: layer2+3") + assertContains(t, out, "lacpRate: slow") + assertContains(t, out, "miimon: 100") + assertContains(t, out, "updelay: 200") + assertContains(t, out, "downdelay: 300") +} + +// Contract: mtu and routes are settable on an extraLinks entry, both on a +// bond and on a plain link. A default route is a routes entry with only a +// gateway, matching what the discovery path emits. +func TestContract_NetworkMultidoc_ExtraLinks_MtuAndRoutes_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth9", + "addresses": []any{"203.0.113.20/24"}, + "mtu": 1400, + "routes": []any{ + map[string]any{"gateway": "203.0.113.1"}, + map[string]any{"destination": "198.51.100.0/24", "gateway": "203.0.113.2"}, + }, + }, + }, + }, + }) + assertContains(t, out, "mtu: 1400") + assertContains(t, out, "gateway: 203.0.113.1") + assertContains(t, out, "destination: 198.51.100.0/24") + assertContains(t, out, "gateway: 203.0.113.2") +} + +// Contract: a VLAN child carries its own mtu and routes. This is the +// external-uplink shape: a tagged VLAN on a bond holding the default route. +func TestContract_NetworkMultidoc_ExtraLinks_VlanMtuAndRoutes_Cozystack(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "bond": map[string]any{"interfaces": []any{"enp3s0"}, "mode": "802.3ad"}, + "vlans": []any{ + map[string]any{ + "vlanId": 217, + "addresses": []any{"203.0.113.10/27"}, + "mtu": 1400, + "routes": []any{map[string]any{"gateway": "203.0.113.1"}}, + }, + }, + }, + }, + }, + }) + assertContains(t, out, "kind: VLANConfig") + assertContains(t, out, "vlanID: 217") + assertContains(t, out, "mtu: 1400") + assertContains(t, out, "gateway: 203.0.113.1") +} + +// Contract: a routes entry without a gateway fails fast rather than +// emitting a route document Talos rejects. +func TestContract_NetworkMultidoc_ExtraLinks_RouteNoGateway_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth9", + "addresses": []any{"203.0.113.20/24"}, + "routes": []any{map[string]any{"destination": "198.51.100.0/24"}}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a routes entry with no gateway") + } + if !strings.Contains(err.Error(), "gateway") { + t.Errorf("error should explain the missing gateway, got %v", err) + } +} + +// Contract: under preserveExisting the typed rebuild is skipped and the +// running interfaces are emitted verbatim, so an extraLinks entry naming +// one of those preserved devices would declare it twice from two sources. +// Talos does not reject a v1alpha1-vs-document link conflict, so the +// render must. +func TestContract_NetworkMultidoc_ExtraLinks_CollidesWithPreserved_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, legacyInterfacesLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "preserveExisting": true, + "extraLinks": []any{ + map[string]any{"interface": "eth0", "addresses": []any{"203.0.113.10/24"}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for an extraLinks entry naming a preserved interface") + } + if !strings.Contains(err.Error(), "collides") { + t.Errorf("error should explain the collision, got %v", err) + } +} + +// Contract: the preserved-name guard must not over-fire — a genuinely new +// link alongside a preserved block still renders. +func TestContract_NetworkMultidoc_ExtraLinks_NewLinkWithPreserved_Cozystack(t *testing.T) { + out := renderCozystackWith(t, legacyInterfacesLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "preserveExisting": true, + "extraLinks": []any{ + map[string]any{"interface": "eth9", "addresses": []any{"203.0.113.10/24"}}, + }, + }, + }) + assertContains(t, out, "interface: eth0") + assertContains(t, out, "kind: LinkConfig") + assertContains(t, out, "name: eth9") +} + +// Contract: an entry carrying only mtu (or only routes) declares no link, +// so it would emit nothing at all. Fail rather than silently no-op. +func TestContract_NetworkMultidoc_ExtraLinks_MtuOnly_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{map[string]any{"interface": "eth9", "mtu": 9000}}, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for an extraLinks entry with only mtu") + } + if !strings.Contains(err.Error(), "eth9") { + t.Errorf("error should name the offending interface, got %v", err) + } +} + +func TestContract_NetworkMultidoc_ExtraLinks_RoutesOnly_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "eth8", "routes": []any{map[string]any{"gateway": "203.0.113.1"}}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for an extraLinks entry with only routes") + } + if !strings.Contains(err.Error(), "eth8") { + t.Errorf("error should name the offending interface, got %v", err) + } +} + +// Contract: a VIP must not also be declared as a static address on an +// extraLinks link — the discovered path strips exactly this, so the +// declarative path refuses it instead of shipping both. +func TestContract_NetworkMultidoc_ExtraLinks_AddressIsVIP_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{map[string]any{"link": "eth9", "ip": "203.0.113.254"}}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "eth9", "addresses": []any{"203.0.113.254/24"}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for an extraLinks address that is also a VIP") + } + if !strings.Contains(err.Error(), "203.0.113.254") { + t.Errorf("error should name the VIP, got %v", err) + } +} + +// Contract: the same guard applies to a VLAN child's addresses. +func TestContract_NetworkMultidoc_ExtraLinks_VlanAddressIsVIP_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "vips": []any{map[string]any{"link": "bond1.7", "ip": "198.51.100.254"}}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "bond": map[string]any{"interfaces": []any{"enp3s0"}, "mode": "802.3ad"}, + "vlans": []any{map[string]any{"vlanId": 7, "addresses": []any{"198.51.100.254/24"}}}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a VLAN address that is also a VIP") + } + if !strings.Contains(err.Error(), "198.51.100.254") { + t.Errorf("error should name the VIP, got %v", err) + } +} + +// Contract: Talos BondConfig.Validate() errors with "bond mode must be +// specified" when bondMode is absent, so a bond without mode fails at +// render instead of on the node. +func TestContract_NetworkMultidoc_ExtraLinks_BondNoMode_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "bond1", "bond": map[string]any{"interfaces": []any{"enp3s0"}}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a bond with no mode") + } + if !strings.Contains(err.Error(), "mode") { + t.Errorf("error should explain the missing mode, got %v", err) + } +} + +// Contract: VLANConfig.Validate() requires vlanID between 1 and 4094. +func TestContract_NetworkMultidoc_ExtraLinks_VlanIdOutOfRange_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "eth0", "vlans": []any{map[string]any{"vlanId": 5000}}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a vlanId outside 1-4094") + } + if !strings.Contains(err.Error(), "4094") { + t.Errorf("error should state the valid range, got %v", err) + } +} + +// Contract: an address without a prefix length fails Talos at DECODE time +// (netip.ParsePrefix), before validation. The discovery path filters +// malformed CIDRs; the declarative path must refuse them. +func TestContract_NetworkMultidoc_ExtraLinks_AddressNoPrefix_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "eth9", "addresses": []any{"203.0.113.10"}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for an address with no prefix length") + } + if !strings.Contains(err.Error(), "203.0.113.10") { + t.Errorf("error should name the malformed address, got %v", err) + } +} + +// Contract: an entry with no interface would emit a document with an empty +// name, which Talos rejects with "name must be specified". +func TestContract_NetworkMultidoc_ExtraLinks_NoInterface_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{map[string]any{"addresses": []any{"203.0.113.10/24"}}}, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for an extraLinks entry with no interface") + } + if !strings.Contains(err.Error(), "interface") { + t.Errorf("error should explain the missing interface, got %v", err) + } +} + +// Contract: a preserved interfaces entry selected by deviceSelector has no +// name resolvable at render time, so the extraLinks collision guard cannot +// prove the operator is not redeclaring that same device. Refuse the +// combination rather than let a silent double-declaration through. +func TestContract_NetworkMultidoc_ExtraLinks_PreservedDeviceSelector_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, legacyDeviceSelectorLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "preserveExisting": true, + "extraLinks": []any{ + map[string]any{"interface": "eth9", "addresses": []any{"203.0.113.10/24"}}, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for extraLinks alongside a deviceSelector-preserved interface") + } + if !strings.Contains(err.Error(), "deviceSelector") { + t.Errorf("error should explain the unresolvable device name, got %v", err) + } +} + +// Contract: the same preserved block WITHOUT extraLinks still renders — +// the guard is about the combination, not about deviceSelector itself. +func TestContract_NetworkMultidoc_PreservedDeviceSelector_NoExtraLinks_Cozystack(t *testing.T) { + out := renderCozystackWith(t, legacyDeviceSelectorLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{"preserveExisting": true}, + }) + assertContains(t, out, "deviceSelector") + assertContains(t, out, "192.168.1.10/24") +} + +// Contract: a route gateway is decoded into netip.Addr and a destination +// into netip.Prefix, so a malformed value fails on the node at decode time +// with a message that does not name the document. Refuse at render, the +// same way addresses already are. +func TestContract_NetworkMultidoc_ExtraLinks_RouteBadGateway_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth9", "addresses": []any{"203.0.113.10/24"}, + "routes": []any{map[string]any{"gateway": "not-an-ip"}}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a malformed route gateway") + } + if !strings.Contains(err.Error(), "not-an-ip") { + t.Errorf("error should name the malformed gateway, got %v", err) + } +} + +func TestContract_NetworkMultidoc_ExtraLinks_RouteBadDestination_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth9", "addresses": []any{"203.0.113.10/24"}, + "routes": []any{map[string]any{"gateway": "203.0.113.1", "destination": "10.0.0.1"}}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a destination that is not a CIDR") + } + if !strings.Contains(err.Error(), "10.0.0.1") { + t.Errorf("error should name the malformed destination, got %v", err) + } +} + +// Contract: bondMode is a string enum; an unknown value fails on the node +// with "unknown bond mode", so it is refused at render. +func TestContract_NetworkMultidoc_ExtraLinks_BadBondMode_Fails_Cozystack(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "bond": map[string]any{"interfaces": []any{"enp3s0"}, "mode": "802.3ax"}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for an unknown bond mode") + } + if !strings.Contains(err.Error(), "802.3ax") { + t.Errorf("error should name the bad mode, got %v", err) + } +} + +// Contract: the generic preset renders the extraLinks accept path too. +// The emission is shared, but the presets differ in defaults, so the +// happy path is pinned on both. +func TestContract_NetworkMultidoc_ExtraLinks_Generic(t *testing.T) { + out := renderGenericWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "mtu": 1400, + "bond": map[string]any{ + "interfaces": []any{"enp3s0", "enp4s0"}, + "mode": "802.3ad", + "lacpRate": "slow", + }, + "addresses": []any{"203.0.113.10/24"}, + "vlans": []any{ + map[string]any{ + "vlanId": 7, + "addresses": []any{"198.51.100.10/24"}, + "routes": []any{map[string]any{"gateway": "198.51.100.1"}}, + }, + }, + }, + }, + }, + }) + assertContains(t, out, "kind: BondConfig") + assertContains(t, out, "name: bond1") + assertContains(t, out, "bondMode: 802.3ad") + assertContains(t, out, "lacpRate: slow") + assertContains(t, out, "mtu: 1400") + assertContains(t, out, "kind: VLANConfig") + assertContains(t, out, "name: bond1.7") + assertContains(t, out, "gateway: 198.51.100.1") +} + +// === VIP address canonicalisation === + +// Contract: a VIP whose spelling differs from the discovered address is +// still stripped from LinkConfig.addresses. One IPv6 address has many +// textual forms; Talos reports the canonical one, while values.yaml +// carries whatever the operator typed. Comparing the two as raw strings +// leaves the VIP declared twice — once as a permanent address and once +// as the Layer2VIPConfig target — which is the leader/follower split the +// strip exists to prevent. +func TestContract_NetworkMultidoc_VipStrippedRegardlessOfSpelling(t *testing.T) { + out := renderCozystackWith(t, ipv6VipActiveOnLinkLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "floatingIP": "2001:0DB8::5", + }) + // The permanent addresses stay. + assertContains(t, out, "192.168.201.10/24") + assertContains(t, out, "2001:db8::10/64") + // The VIP-bearing address must not appear as a static address. + assertNotContains(t, out, "2001:db8::5/128") + assertContains(t, out, "kind: Layer2VIPConfig") +} + +// Contract: the canonical spelling keeps working — the fix must not +// trade one direction of the comparison for the other. +func TestContract_NetworkMultidoc_VipStrippedCanonicalSpelling(t *testing.T) { + out := renderCozystackWith(t, ipv6VipActiveOnLinkLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "floatingIP": "2001:db8::5", + }) + assertNotContains(t, out, "2001:db8::5/128") + assertContains(t, out, "kind: Layer2VIPConfig") +} + +// Contract: two spellings of one address are one VIP, so declaring it +// under floatingIP and again under vips is a duplicate. Both documents +// would be named after the same address and Talos rejects the pair on +// apply. +func TestContract_NetworkMultidoc_VipDuplicateAcrossSpellings_Fails(t *testing.T) { + err := renderCozystackExpectError(t, ipv6VipActiveOnLinkLookup(), map[string]any{ + "floatingIP": "2001:0DB8::5", + "vips": []any{ + map[string]any{"ip": "2001:db8::5", "link": "eth1"}, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for one VIP declared under two spellings") + } + + if !strings.Contains(err.Error(), "more than once") { + t.Errorf("error should name the duplicate, got %v", err) + } +} + +// Contract: the addresses-vs-VIP guard on extraLinks compares addresses +// the same way, so a differently-spelled VIP as a static extraLinks +// address is still refused. +func TestContract_NetworkMultidoc_ExtraLinksAddressIsVipAcrossSpellings_Fails(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "vips": []any{ + map[string]any{"ip": "2001:db8::5", "link": "eth0"}, + }, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth9", + "addresses": []any{"2001:0DB8::5/64"}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for an extraLinks address that is also a declared VIP") + } + + if !strings.Contains(err.Error(), "VIP") { + t.Errorf("error should explain the VIP collision, got %v", err) + } +} + +// === extraLinks numeric and CIDR validation === + +// Contract: the prefix half of a CIDR is validated too. A valid IP with +// a nonsense prefix (/33 on IPv4, /abc, a bare trailing slash) passes an +// IP-only check and then fails on the node at decode time, in a message +// that never names the offending document. +func TestContract_NetworkMultidoc_ExtraLinksAddressBadPrefix_Fails(t *testing.T) { + for _, address := range []string{"203.0.113.10/33", "203.0.113.10/abc", "203.0.113.10/"} { + t.Run(address, func(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{"interface": "eth9", "addresses": []any{address}}, + }, + }, + }) + if err == nil { + t.Fatalf("expected a fail-fast for address %q", address) + } + + if !strings.Contains(err.Error(), "prefix length") { + t.Errorf("error should name the bad prefix, got %v", err) + } + }) + } +} + +// Contract: a route destination gets the same prefix check as an address. +func TestContract_NetworkMultidoc_ExtraLinksRouteDestinationBadPrefix_Fails(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth9", + "addresses": []any{"203.0.113.10/24"}, + "routes": []any{ + map[string]any{"gateway": "203.0.113.1", "destination": "198.51.100.0/foo"}, + }, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a route destination with a bad prefix") + } + + if !strings.Contains(err.Error(), "prefix length") { + t.Errorf("error should name the bad prefix, got %v", err) + } +} + +// Contract: mtu is a uint32 on the wire. A non-integer, a negative, an +// out-of-range value, and a bare 0 (which the truthiness check would +// otherwise drop without a word) are all refused at render. +func TestContract_NetworkMultidoc_ExtraLinksBadMTU_Fails(t *testing.T) { + cases := []struct { + name string + mtu any + }{ + {"string", "jumbo"}, + {"negative", -5}, + {"fractional", 1400.5}, + {"too-large", 99999}, + {"zero", 0}, + {"below-ethernet-minimum", 42}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth9", + "addresses": []any{"203.0.113.10/24"}, + "mtu": tc.mtu, + }, + }, + }, + }) + if err == nil { + t.Fatalf("expected a fail-fast for mtu %v", tc.mtu) + } + + if !strings.Contains(err.Error(), "mtu") { + t.Errorf("error should name the mtu field, got %v", err) + } + }) + } +} + +// Contract: a legitimate jumbo mtu still renders. +func TestContract_NetworkMultidoc_ExtraLinksJumboMTU_Renders(t *testing.T) { + out := renderCozystackWith(t, simpleNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth9", + "addresses": []any{"203.0.113.10/24"}, + "mtu": 9000, + }, + }, + }, + }) + assertContains(t, out, "mtu: 9000") +} + +// Contract: a fractional vlanId passes the range check through int +// truncation but renders verbatim — both as vlanID and inside the +// document name (eth9.7.5) — and Talos rejects it at decode. Refuse it +// at render instead. +func TestContract_NetworkMultidoc_ExtraLinksFractionalVlanID_Fails(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "eth9", + "addresses": []any{"203.0.113.10/24"}, + "vlans": []any{ + map[string]any{"vlanId": 7.5, "addresses": []any{"198.51.100.10/24"}}, + }, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a fractional vlanId") + } + + if !strings.Contains(err.Error(), "whole number") { + t.Errorf("error should explain the integer requirement, got %v", err) + } +} + +// === extraLinks bond slaves === + +// Contract: a link enslaved by a declared bond gets no document of its +// own. The discovery path drops any link whose spec.slaveKind is set, +// because a standalone LinkConfig next to the master's links[] entry is +// a conflicting declaration; on the first apply the bond does not exist +// yet, so the declarative path has to apply the same filter itself. +func TestContract_NetworkMultidoc_ExtraLinksBondSlaveGetsNoOwnDocument(t *testing.T) { + out := renderCozystackWith(t, spareNicsLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "bond": map[string]any{"interfaces": []any{"eth2", "eth3"}, "mode": "802.3ad"}, + "addresses": []any{"203.0.113.10/24"}, + }, + }, + }, + }) + assertContains(t, out, "kind: BondConfig") + assertContains(t, out, "name: bond1") + // The slaves appear only under the bond, never as document names. + for doc := range strings.SplitSeq(out, "\n---\n") { + if !strings.Contains(doc, "kind: LinkConfig") { + continue + } + + for _, slave := range []string{"name: eth2", "name: eth3"} { + if strings.Contains(doc, slave) { + t.Errorf("%s is enslaved by bond1 but still got its own LinkConfig:\n%s", slave, doc) + } + } + } + // The primary link keeps its document. + assertContains(t, out, "name: eth0") +} + +// Contract: enslaving a link that carries addresses, without saying where +// that addressing goes, fails instead of silently dropping it. Filtering +// the slave's document would take the node's addressing away, and the +// render cannot infer whether the operator meant to move it. +func TestContract_NetworkMultidoc_ExtraLinksBondSlaveWithAddresses_Fails(t *testing.T) { + err := renderCozystackExpectError(t, multiNicLookup(), map[string]any{ + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "bond": map[string]any{"interfaces": []any{"eth1"}, "mode": "802.3ad"}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a bond slave carrying the node's addressing") + } + + if !strings.Contains(err.Error(), "eth1") { + t.Errorf("error should name the slave, got %v", err) + } +} + +// Contract: the remediation the guard prescribes actually works. Moving an +// already-addressed NIC into a bond is the headline reason to reach for +// extraLinks, so restating its addressing on the bond entry is accepted: +// the slave loses its document and the bond carries what it used to. +func TestContract_NetworkMultidoc_ExtraLinksBondSlaveAddressesMovedOntoBond(t *testing.T) { + out := renderCozystackWith(t, multiNicLookup(), map[string]any{ + "advertisedSubnets": []any{testAdvertisedSubnet}, + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond0", + "bond": map[string]any{"interfaces": []any{"eth0", "eth1"}, "mode": "802.3ad"}, + "addresses": []any{"192.168.201.10/24", "10.0.0.5/24"}, + "routes": []any{ + map[string]any{"gateway": "192.168.201.1"}, + map[string]any{"gateway": "10.0.0.1", "destination": "10.0.0.0/24"}, + }, + }, + }, + }, + }) + assertContains(t, out, "kind: BondConfig") + assertContains(t, out, "name: bond0") + assertContains(t, out, " - address: 192.168.201.10/24") + assertContains(t, out, " - gateway: 192.168.201.1") + + for doc := range strings.SplitSeq(out, "\n---\n") { + if !strings.Contains(doc, "kind: LinkConfig") { + continue + } + + for _, slave := range []string{"name: eth0", "name: eth1"} { + if strings.Contains(doc, slave) { + t.Errorf("%s is enslaved by bond0 but still got its own LinkConfig:\n%s", slave, doc) + } + } + } +} + +// Contract: the default route rides on the slave's own document, which the +// filter drops, so a bond over the default-route link must restate it. An +// entry that moves the addresses but forgets the route fails, naming the +// gateway to restate. +func TestContract_NetworkMultidoc_ExtraLinksBondSlaveDefaultRouteNotMoved_Fails(t *testing.T) { + err := renderCozystackExpectError(t, multiNicLookup(), map[string]any{ + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond0", + "bond": map[string]any{"interfaces": []any{"eth0", "eth1"}, "mode": "802.3ad"}, + "addresses": []any{"192.168.201.10/24"}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a bond over the default-route link with no route restated") + } + + if !strings.Contains(err.Error(), "192.168.201.1") { + t.Errorf("error should name the gateway to restate, got %v", err) + } +} + +// Contract: the default-route check is about a default route, not about +// the routes list being non-empty. A destination-scoped route leaves the +// node with no way off its subnet, so it does not satisfy the check. +func TestContract_NetworkMultidoc_ExtraLinksBondSlaveScopedRouteIsNotDefault_Fails(t *testing.T) { + err := renderCozystackExpectError(t, multiNicLookup(), map[string]any{ + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond0", + "bond": map[string]any{"interfaces": []any{"eth0"}, "mode": "802.3ad"}, + "addresses": []any{"192.168.201.10/24"}, + "routes": []any{ + map[string]any{"gateway": "192.168.201.1", "destination": "172.16.0.0/12"}, + }, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast: a scoped route is not a default route") + } + + if !strings.Contains(err.Error(), "destination-less") { + t.Errorf("error should ask for a destination-less route, got %v", err) + } +} + +// Contract: a slave's static routes are lost with its document just as its +// addresses are — quieter, because the default path still works and only a +// subnet goes unreachable. eth1 carries 10.0.0.0/24 via 10.0.0.1. +func TestContract_NetworkMultidoc_ExtraLinksBondSlaveStaticRouteNotMoved_Fails(t *testing.T) { + err := renderCozystackExpectError(t, multiNicLookup(), map[string]any{ + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "bond": map[string]any{"interfaces": []any{"eth1"}, "mode": "802.3ad"}, + "addresses": []any{"10.0.0.5/24"}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a bond slave whose static route is not restated") + } + + if !strings.Contains(err.Error(), "10.0.0.0/24") { + t.Errorf("error should name the route to restate, got %v", err) + } +} + +// Contract: a slave that another extraLinks entry declares as a link of +// its own is the same conflicting pair, with both halves written by the +// operator. +func TestContract_NetworkMultidoc_ExtraLinksBondSlaveDeclaredElsewhere_Fails(t *testing.T) { + err := renderCozystackExpectError(t, simpleNicLookup(), map[string]any{ + "network": map[string]any{ + "extraLinks": []any{ + map[string]any{ + "interface": "bond1", + "bond": map[string]any{"interfaces": []any{"eth9"}, "mode": "802.3ad"}, + "addresses": []any{"203.0.113.10/24"}, + }, + map[string]any{ + "interface": "eth9", + "addresses": []any{"198.51.100.10/24"}, + }, + }, + }, + }) + if err == nil { + t.Fatal("expected a fail-fast for a bond slave declared as its own link") + } + + if !strings.Contains(err.Error(), "eth9") { + t.Errorf("error should name the slave, got %v", err) + } +} diff --git a/pkg/engine/helm/engine.go b/pkg/engine/helm/engine.go index 0bf1135..e639a9d 100644 --- a/pkg/engine/helm/engine.go +++ b/pkg/engine/helm/engine.go @@ -264,6 +264,7 @@ func (e Engine) initFunMap(tmpl *template.Template) { funcMap["cidrContains"] = cidrContains funcMap["cidrPrefixLen"] = cidrPrefixLen funcMap["ipIsValid"] = ipIsValid + funcMap["ipCanonical"] = ipCanonical tmpl.Funcs(funcMap) } @@ -314,6 +315,29 @@ func ipIsValid(addrStr string) (bool, error) { return true, nil } +// ipCanonical returns the canonical text form of an IP literal, and the input +// verbatim when it does not parse. IPv6 has many spellings for one address +// (2001:0DB8::5, 2001:db8:0:0:0:0:0:5, 2001:db8::5) and Talos reports +// discovered addresses in netip's canonical form, so a chart-side comparison +// between an operator-written VIP and a discovered address has to normalise +// both sides first or it silently misses a match — leaving the same address +// declared both as a static address and as a VIP. +// +// Unparseable input is returned unchanged rather than erroring, matching the +// lenient contract of the other net helpers: the callers run over operator +// values that are validated separately by ipIsValid, and over COSI address +// entries where a single corrupt row must not crash the render. An +// unparseable value simply fails to equal anything canonical. +func ipCanonical(addrStr string) (string, error) { + addr, err := netip.ParseAddr(addrStr) + if err != nil { + //nolint:nilerr // parse-failure deliberately yields the input verbatim, see docstring + return addrStr, nil + } + + return addr.String(), nil +} + // cidrContains reports whether the given IP literal falls inside the given // CIDR. Used by the multi-doc Layer2VIPConfig discovery path to pick the link // whose subnet hosts the operator-declared floatingIP — net/netip handles diff --git a/pkg/engine/render_test.go b/pkg/engine/render_test.go index 81d2eb0..f3b1ce8 100644 --- a/pkg/engine/render_test.go +++ b/pkg/engine/render_test.go @@ -6440,6 +6440,180 @@ func vipActiveOnLinkLookup() func(string, string, string) (map[string]any, error } } +// spareNicsLookup is the shape the extraLinks bond example targets: a +// primary NIC carrying the node's addressing and default route, plus two +// physically present but unaddressed NICs waiting to be bonded. On the +// first apply the bond does not exist yet, so discovery still reports +// the future slaves as ordinary configurable links. +func spareNicsLookup() func(string, string, string) (map[string]any, error) { + nic := func(id string, index int, mac, bus string) map[string]any { + return map[string]any{ + "metadata": map[string]any{"id": id}, + "spec": map[string]any{ + "kind": "physical", + "index": index, + "hardwareAddr": mac, + "busPath": bus, + }, + } + } + eth0 := nic("eth0", 1, "aa:bb:cc:00:00:01", "pci-0000:00:1f.0") + eth2 := nic("eth2", 2, "aa:bb:cc:00:00:02", "pci-0000:00:1f.1") + eth3 := nic("eth3", 3, "aa:bb:cc:00:00:03", "pci-0000:00:1f.2") + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "eth0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0, eth2, eth3}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{"dnsServers": []any{"8.8.8.8"}}, + } + + return func(resource, _, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + switch id { + case "eth0": + return eth0, nil + case "eth2": + return eth2, nil + case "eth3": + return eth3, nil + case "": + return linksList, nil + } + + return map[string]any{}, nil + case "addresses": + return addressesList, nil + case "nodeaddress": + if id == "default" { + return nodeDefault, nil + } + case "resolvers": + if id == "resolvers" { + return resolvers, nil + } + } + + return map[string]any{}, nil + } +} + +// ipv6VipActiveOnLinkLookup is vipActiveOnLinkLookup's dual-stack +// sibling: eth0 carries the IPv4 default route plus a permanent IPv6 +// address and an active IPv6 VIP. COSI reports addresses in netip's +// canonical form (2001:db8::5/128), which is what makes it possible +// for an operator-written spelling of the same address to differ. +func ipv6VipActiveOnLinkLookup() func(string, string, string) (map[string]any, error) { + eth0 := map[string]any{ + "metadata": map[string]any{"id": "eth0"}, + "spec": map[string]any{ + "kind": "physical", + "index": 1, + "hardwareAddr": "aa:bb:cc:00:00:01", + "busPath": "pci-0000:00:1f.0", + }, + } + routesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{ + "spec": map[string]any{ + "dst": "", + "gateway": "192.168.201.1", + "outLinkName": "eth0", + "family": "inet4", + "table": "main", + }, + }, + }, + } + linksList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{eth0}, + } + addressesList := map[string]any{ + "apiVersion": "v1", + "kind": "List", + "items": []any{ + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "192.168.201.10/24", "family": "inet4", "scope": "global"}}, + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "2001:db8::10/64", "family": "inet6", "scope": "global"}}, + map[string]any{"spec": map[string]any{"linkName": "eth0", "address": "2001:db8::5/128", "family": "inet6", "scope": "global"}}, + }, + } + nodeDefault := map[string]any{ + "spec": map[string]any{ + "addresses": []any{"192.168.201.10/24"}, + }, + } + resolvers := map[string]any{ + "spec": map[string]any{ + "dnsServers": []any{"8.8.8.8"}, + }, + } + + return func(resource, _, id string) (map[string]any, error) { + switch resource { + case "routes": + return routesList, nil + case "links": + if id == "eth0" { + return eth0, nil + } + + if id == "" { + return linksList, nil + } + + return map[string]any{}, nil + case "addresses": + return addressesList, nil + case "nodeaddress": + if id == "default" { + return nodeDefault, nil + } + case "resolvers": + if id == "resolvers" { + return resolvers, nil + } + } + + return map[string]any{}, nil + } +} + // bridgeWithGatewayLookup returns a lookup fixture where a discovered // bridge br0 carries the IPv4 default route (typical shape: VMs sit // behind br0, the bridge gets the host's address). The renderer @@ -6863,6 +7037,40 @@ func renderCozystackWith(t *testing.T, lookup func(string, string, string) (map[ return out["cozystack/templates/controlplane.yaml"] } +// renderCozystackWorkerWith mirrors renderCozystackWith but returns the +// rendered worker template, for pinning behavior that must also hold on +// worker nodes (e.g. a vips entry on a storage link). +func renderCozystackWorkerWith(t *testing.T, lookup func(string, string, string) (map[string]any, error), overrides map[string]any) string { + t.Helper() + origLookup := helmEngine.LookupFunc + t.Cleanup(func() { helmEngine.LookupFunc = origLookup }) + helmEngine.LookupFunc = lookup + + chrt, err := loader.LoadDir("../../charts/cozystack") + if err != nil { + t.Fatalf("load chart: %v", err) + } + + values := cloneValues(chrt.Values) + if v, _ := values["endpoint"].(string); v == "" { + values["endpoint"] = testEndpoint + } + + maps.Copy(values, overrides) + + eng := helmEngine.Engine{} + + out, err := eng.Render(chrt, common.Values{ + "Values": values, + "TalosVersion": "v1.12", + }) + if err != nil { + t.Fatalf("render: %v", err) + } + + return out["cozystack/templates/worker.yaml"] +} + // renderCozystackExpectError mirrors renderCozystackWith but returns // the render error to the caller instead of t.Fatal'ing on it. Used // by tests that pin error-message contracts (fail-fast on malformed