feat(k8s): cluster naming (#243)

* chore(netbird): move to kebab naming

Render NetBird object names (groups, setup keys, policies, networks,
network-resources) as lowercase-kebab instead of UPPER_SNAKE, e.g.
YUCCA_PROD_HTZ_FSN1_MGMT → yucca-prod-htz-fsn1-mgmt. The 1Password setup-key
item titles stay UPPER_SNAKE (decoupled) so CI/ansible/talos op:// consumers
keep resolving.

Pin the futo-org/netbird provider to 1.0.2, which fixes the group
resources TF→API decode so a resource-tag group (htz-fsn1 `resources`) can be
renamed in place — no name pin needed.

* update locks

* chore(naming): naming names
This commit is contained in:
Antoine Lecompte
2026-06-30 13:16:50 -04:00
committed by GitHub
parent 75b7808993
commit 254b7a4b49
18 changed files with 188 additions and 38 deletions
+16
View File
@@ -187,3 +187,19 @@ highest tag to staging → production is gated behind a reviewed `promote-produc
trailing commas, width 120.
- Generated files are eslint-ignored: `**/fetch-client.ts`, `packages/web/src/locales`, `dist`,
`build`, `.svelte-kit`.
### Naming
- **Cluster names are themed by workload.** Kubernetes clusters → **Star Wars** (`luke` =
staging, `father` = the soon-to-be prod). Ceph clusters → **Dune** (`sietch`, `spice`, …).
Choose the next themed name when standing up a cluster; it's the Talos/Ceph cluster name +
the `<clustername>` hostname segment.
- **Node hostnames** follow `<product>-<provider>-<region>-<clustername>-<role>-<nodename>` —
e.g. a staging Talos node is `yucca-int-aus-luke-k8s-<word>`:
- `product` = `yucca`; `role` = the workload segment (`k8s` for Talos nodes, `ceph` for Ceph).
- `provider` / `region` = the **3-letter** `provider_code` / `region_code` from the region's
`region.hcl` (austin = `int`/`aus`, htz-fsn1 = `htz`/`fsn`).
- `<clustername>` = the themed cluster name (above).
- `<nodename>` = auto-picked from the shared name inventory
(`tf/shared/modules/node-names/wordlist.txt`) — deterministic per cluster, unique within it;
pass an explicit node `name` to override.
@@ -51,6 +51,12 @@ variable "provider_code" {
default = null
}
variable "region_code" {
description = "3-letter region segment of node hostnames (null for global)."
type = string
default = null
}
variable "domain" {
description = "Region FQDN suffix (null for global)."
type = string
+2 -1
View File
@@ -3,6 +3,7 @@ locals {
role = "primary"
site_id = 40
datacenter = "fsn1"
provider_code = "htz"
provider_code = "htz" # 3-letter provider segment of node hostnames
region_code = "fsn" # 3-letter region segment of node hostnames
domain = "prod.fsn1.htz.futo.cloud"
}
@@ -51,6 +51,12 @@ variable "provider_code" {
default = null
}
variable "region_code" {
description = "3-letter region segment of node hostnames (null for global)."
type = string
default = null
}
variable "domain" {
description = "Region FQDN suffix (null for global)."
type = string
+2 -1
View File
@@ -7,6 +7,7 @@ locals {
role = "primary"
site_id = null # austin is not a fabric-managed site (no switch fabric)
datacenter = "austin"
provider_code = "int"
provider_code = "int" # 3-letter provider segment of node hostnames
region_code = "aus" # 3-letter region segment of node hostnames
domain = "staging.austin.int.futo.cloud"
}
@@ -7,7 +7,9 @@
# reachable across the install reboot.
clusters = {
yucca-staging = {
# Star Wars-themed cluster name (staging = luke). Node hostnames derive as
# yucca-int-aus-luke-k8s-<name> (name auto-picked from the shared inventory).
luke = {
talos_version = "1.13.4" # latest stable (v1.13.4); default k8s = 1.36.1
kubernetes_version = "v1.36.1"
@@ -105,9 +107,9 @@ clusters = {
}
nodes = [
{ name = "staging-cp1", role = "control-plane", address = "10.10.10.47" },
{ name = "staging-cp2", role = "control-plane", address = "10.10.10.242" },
{ name = "staging-cp3", role = "control-plane", address = "10.10.10.117" },
{ role = "control-plane", address = "10.10.10.47" },
{ role = "control-plane", address = "10.10.10.242" },
{ role = "control-plane", address = "10.10.10.117" },
]
}
}
@@ -3,6 +3,8 @@ module "cluster" {
source = "../../../../shared/modules/talos-baremetal"
cluster_name = each.key
provider_code = var.provider_code
region_code = var.region_code
talos_version = each.value.talos_version
kubernetes_version = each.value.kubernetes_version
talos_schematic_id = each.value.talos_schematic_id
@@ -51,6 +51,12 @@ variable "provider_code" {
default = null
}
variable "region_code" {
description = "3-letter region segment of node hostnames (null for global)."
type = string
default = null
}
variable "domain" {
description = "Region FQDN suffix (null for global)."
type = string
@@ -154,7 +154,7 @@ variable "netbird_operator_api_token" {
}
variable "clusters" {
description = "Map of bare-metal Talos cluster specs keyed by short cluster name. Declarative: add/modify an entry in clusters.auto.tfvars + tf:apply."
description = "Map of bare-metal Talos cluster specs keyed by themed cluster name (Star Wars; e.g. 'luke'). Declarative: add/modify an entry in clusters.auto.tfvars + tf:apply."
type = map(object({
talos_version = string
kubernetes_version = optional(string)
@@ -5,6 +5,11 @@ terraform {
source = "siderolabs/talos"
version = "~> 0.11"
}
# Hostname picks for the talos nodes (node-names module → random_shuffle).
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
helm = {
source = "hashicorp/helm"
version = "~> 3.1"
@@ -51,6 +51,12 @@ variable "provider_code" {
default = null
}
variable "region_code" {
description = "3-letter region segment of node hostnames (null for global)."
type = string
default = null
}
variable "domain" {
description = "Region FQDN suffix (null for global)."
type = string
+2
View File
@@ -54,6 +54,7 @@ locals {
site_id = lookup(local.region_meta, "site_id", null)
datacenter = lookup(local.region_meta, "datacenter", null)
provider_code = lookup(local.region_meta, "provider_code", null)
region_code = lookup(local.region_meta, "region_code", null)
domain = lookup(local.region_meta, "domain", null)
}
@@ -100,5 +101,6 @@ inputs = {
site_id = local.site_id
datacenter = local.datacenter
provider_code = local.provider_code
region_code = local.region_code
domain = local.domain
}
+15 -23
View File
@@ -15,41 +15,33 @@ terraform {
}
}
locals {
# Wordlist for auto-assigning host names. Names are unique within a cluster
# (not globally) — sietch-ceph-laurel and a hypothetical future
# a second cluster's *-ceph-laurel could coexist, disambiguated by FQDN.
wordlist = compact(split("\n", file("${path.module}/wordlist.txt")))
# Operator-declared names are reserved — strip them from the pool so
# auto-picked names can't collide within this cluster.
explicit_names = [for h in var.hosts : h.name if h.name != null]
available_words = tolist(setsubtract(toset(local.wordlist), toset(local.explicit_names)))
}
# Full shuffle of available words, seeded by cluster name. Position-indexed:
# host[i] with name == null uses result[i]. Adding hosts at the tail is safe;
# existing positions keep their names across applies. Bumping name_seed forces
# a re-roll of the whole cluster (not recommended once hosts exist).
resource "random_shuffle" "names" {
input = local.available_words
result_count = length(local.available_words)
keepers = {
# Auto host names come from the shared node-names inventory (the wordlist used to
# live here; it now backs both talos + ceph). cluster_name seeds the shuffle so each
# cluster gets its own permutation; explicit host names are excluded from the pool.
module "names" {
source = "../node-names"
cluster_name = var.cluster_name
name_seed = var.name_seed
names = [for h in var.hosts : h.name]
}
# The wordlist + shuffle moved into node-names — preserve the existing shuffle state
# so host names don't re-randomize on this refactor.
moved {
from = random_shuffle.names
to = module.names.random_shuffle.names
}
locals {
# Resolve each host's final name: explicit or auto-picked from shuffle.
hosts_computed = [
for i, h in var.hosts : {
name = h.name != null ? h.name : random_shuffle.names.result[i]
name = module.names.resolved[i]
bond_ip = h.bond_ip
bootstrap = coalesce(h.bootstrap, false)
roles = h.roles
hostname_short = "${var.cluster_name}-${var.role_in_hostname}-${h.name != null ? h.name : random_shuffle.names.result[i]}"
fqdn = "${var.cluster_name}-${var.role_in_hostname}-${h.name != null ? h.name : random_shuffle.names.result[i]}.${var.domain}"
hostname_short = "${var.cluster_name}-${var.role_in_hostname}-${module.names.resolved[i]}"
fqdn = "${var.cluster_name}-${var.role_in_hostname}-${module.names.resolved[i]}.${var.domain}"
}
]
+51
View File
@@ -0,0 +1,51 @@
# node-names — the shared name inventory for auto-naming nodes/hosts. Resolves a
# per-slot list of names: an explicit override where given, else a deterministic
# pick from the wordlist. Names are unique within a cluster (the shuffle is seeded
# by cluster_name; explicit names are excluded from the pool so they can't collide).
# Position-indexed: slot[i] with no explicit name gets result[i], so appending
# slots at the tail is stable (existing slots keep their names across applies).
terraform {
required_version = ">= 1.6"
required_providers {
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}
variable "cluster_name" {
type = string
description = "Seeds the shuffle so each cluster gets its own permutation."
}
variable "name_seed" {
type = string
default = "v1"
description = "Bump to re-roll a cluster's auto-names. Do NOT change once nodes are deployed (renames every auto-named node)."
}
variable "names" {
type = list(string)
description = "Per-slot name: an explicit string to reserve, or null to auto-pick. Resolved positionally in `resolved`."
}
locals {
wordlist = compact(split("\n", file("${path.module}/wordlist.txt")))
explicit_names = [for n in var.names : n if n != null]
available_words = tolist(setsubtract(toset(local.wordlist), toset(local.explicit_names)))
}
resource "random_shuffle" "names" {
input = local.available_words
result_count = length(local.available_words)
keepers = {
cluster_name = var.cluster_name
name_seed = var.name_seed
}
}
output "resolved" {
description = "The resolved name per input slot (explicit where given, else auto-picked), in order."
value = [for i, n in var.names : n != null ? n : random_shuffle.names.result[i]]
}
+23 -5
View File
@@ -11,17 +11,35 @@
# kernel `ip=` cmdline. Nodes are dialed directly at their maintenance IP,
# which is also pinned as the post-install static IP on the bond.
# Per-node names from the shared inventory (operator override per node, else auto).
module "names" {
source = "../node-names"
cluster_name = var.cluster_name
name_seed = var.name_seed
names = [for n in var.nodes : n.name]
}
locals {
cluster_endpoint = coalesce(var.cluster_endpoint, "https://${var.cluster_vip}:6443")
# Prefix length from the subnet CIDR (e.g. "24" from "10.10.10.0/24").
netmask = split("/", var.subnet_cidr)[1]
# Stable per-name maps (TF map iteration is alphabetical by key, so plan
# Each node's hostname per the fleet convention:
# <product>-<provider>-<region>-<clustername>-<role>-<nodename>
# e.g. yucca-int-aus-luke-k8s-<word>. <nodename> = nodes[i].name or auto-picked.
nodes_named = [
for i, n in var.nodes : merge(n, {
role = coalesce(n.role, "control-plane")
hostname = "${var.product}-${var.provider_code}-${var.region_code}-${var.cluster_name}-${var.role_in_hostname}-${module.names.resolved[i]}"
})
]
# Stable per-hostname maps (TF map iteration is alphabetical by key, so plan
# ordering is deterministic across runs).
node_map = { for n in var.nodes : n.name => n }
cp_node_map = { for k, n in local.node_map : k => n if coalesce(n.role, "control-plane") == "control-plane" }
worker_node_map = { for k, n in local.node_map : k => n if coalesce(n.role, "control-plane") == "worker" }
node_map = { for n in local.nodes_named : n.hostname => n }
cp_node_map = { for k, n in local.node_map : k => n if n.role == "control-plane" }
worker_node_map = { for k, n in local.node_map : k => n if n.role == "worker" }
cp_addresses = [for k, n in local.cp_node_map : n.address]
@@ -158,7 +176,7 @@ locals {
apiVersion = "v1alpha1"
kind = "HostnameConfig"
auto = "off"
hostname = n.name
hostname = n.hostname
}),
]
}
+33 -2
View File
@@ -1,8 +1,39 @@
variable "cluster_name" {
description = "Short cluster identifier (e.g., yucca-staging). Drives the Talos cluster name + node hostname prefix is supplied per-node in `nodes`."
description = "Themed cluster name (Star Wars for k8s, e.g. 'luke'/'father'). Used as the Talos cluster name + the <clustername> segment of each node hostname."
type = string
}
# Node hostname convention: <product>-<provider>-<region>-<clustername>-<role>-<nodename>
# e.g. yucca-int-aus-luke-k8s-<random>. provider/region are the 3-letter codes
# from the region's region.hcl; <nodename> auto-derives from the shared inventory.
variable "product" {
description = "Product segment of node hostnames."
type = string
default = "yucca"
}
variable "provider_code" {
description = "3-letter provider segment of node hostnames (region.hcl provider_code, e.g. 'int' / 'htz')."
type = string
}
variable "region_code" {
description = "3-letter region segment of node hostnames (region.hcl region_code, e.g. 'aus' / 'fsn')."
type = string
}
variable "role_in_hostname" {
description = "Workload-role segment of node hostnames ('k8s' for Talos clusters). Distinct from the per-node control-plane/worker role."
type = string
default = "k8s"
}
variable "name_seed" {
description = "Seed for the per-node random name pick (shared node-names inventory). Do NOT change once nodes are deployed (renames every auto-named node)."
type = string
default = "v1"
}
variable "talos_version" {
description = "Talos Linux version to install (e.g., '1.13.3'). Sets machine config schema AND the install image tag (so the installed system is exactly this version, regardless of the maintenance-mode boot image)."
type = string
@@ -79,7 +110,7 @@ variable "nodes" {
and Terraform stays reachable across the install reboot).
EOT
type = list(object({
name = string
name = optional(string) # explicit hostname override; null = auto-pick from the shared inventory
role = optional(string, "control-plane")
address = string
}))
@@ -5,5 +5,10 @@ terraform {
source = "siderolabs/talos"
version = "~> 0.11"
}
# Used by the node-names child module (random_shuffle) for hostname picks.
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
}
}