diff --git a/clusters/unraid-lab/apps/cilium-lan-vip/application.yaml b/clusters/unraid-lab/apps/cilium-lan-vip/application.yaml new file mode 100644 index 0000000..b846d0d --- /dev/null +++ b/clusters/unraid-lab/apps/cilium-lan-vip/application.yaml @@ -0,0 +1,22 @@ +# unraid-lab-only. Cilium LB-IPAM + L2 announcement CRs for the LAN VIP +# (openspec: add-metallb-lan-exposure). Cluster-scoped resources — depend +# only on Cilium's own CRDs, which are present before this ever syncs since +# Cilium is the CNI. +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: cilium-lan-vip + namespace: argocd +spec: + project: default + source: + repoURL: https://github.com/syscode-labs/syscode-homelab-gitops-apps.git + targetRevision: HEAD + path: clusters/unraid-lab/apps/cilium-lan-vip/manifests + destination: + server: https://kubernetes.default.svc + namespace: kube-system + syncPolicy: + automated: + prune: true + selfHeal: true diff --git a/clusters/unraid-lab/apps/cilium-lan-vip/manifests/ipaddresspool.yaml b/clusters/unraid-lab/apps/cilium-lan-vip/manifests/ipaddresspool.yaml new file mode 100644 index 0000000..7c4d81f --- /dev/null +++ b/clusters/unraid-lab/apps/cilium-lan-vip/manifests/ipaddresspool.yaml @@ -0,0 +1,12 @@ +apiVersion: cilium.io/v2alpha1 +kind: CiliumLoadBalancerIPPool +metadata: + name: unraid-lab-lan +spec: + blocks: + - cidr: 10.10.210.30/32 + # Opt-in only (openspec: add-metallb-lan-exposure, task 1.3). No Service + # gets this VIP unless explicitly labeled — default stays tailnet-only. + serviceSelector: + matchLabels: + syscode.dev/lan-vip: "true" diff --git a/clusters/unraid-lab/apps/cilium-lan-vip/manifests/kustomization.yaml b/clusters/unraid-lab/apps/cilium-lan-vip/manifests/kustomization.yaml new file mode 100644 index 0000000..79146fd --- /dev/null +++ b/clusters/unraid-lab/apps/cilium-lan-vip/manifests/kustomization.yaml @@ -0,0 +1,6 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - ipaddresspool.yaml + - l2announcementpolicy.yaml diff --git a/clusters/unraid-lab/apps/cilium-lan-vip/manifests/l2announcementpolicy.yaml b/clusters/unraid-lab/apps/cilium-lan-vip/manifests/l2announcementpolicy.yaml new file mode 100644 index 0000000..90801e3 --- /dev/null +++ b/clusters/unraid-lab/apps/cilium-lan-vip/manifests/l2announcementpolicy.yaml @@ -0,0 +1,9 @@ +apiVersion: cilium.io/v2alpha1 +kind: CiliumL2AnnouncementPolicy +metadata: + name: unraid-lab-lan +spec: + loadBalancerIPs: true + serviceSelector: + matchLabels: + syscode.dev/lan-vip: "true" diff --git a/docs/deploy/runbook.md b/docs/deploy/runbook.md index 3aea9b5..f62db91 100644 --- a/docs/deploy/runbook.md +++ b/docs/deploy/runbook.md @@ -23,7 +23,7 @@ cluster's identity), and — OCI only — `cilium` block: ```bash mise run oci-lab:generate-manifests mise run unraid:generate-manifests -git add omni/patches/oci-lab-inline-manifests.yaml clusters/unraid-lab/omni/inline-manifests.yaml +git add omni/cluster-templates/patches/oci-lab-inline-manifests.yaml clusters/unraid-lab/omni/inline-manifests.yaml git commit -m "chore: generate inline-manifests for deploy" ``` diff --git a/omni/cluster-templates/oci-lab.yaml b/omni/cluster-templates/oci-lab.yaml index ba234d0..abea76d 100644 --- a/omni/cluster-templates/oci-lab.yaml +++ b/omni/cluster-templates/oci-lab.yaml @@ -10,16 +10,25 @@ kubernetes: talos: # Keep in step with installImage in omni/machine-classes/*.yaml. version: v1.13.7 +# Patches live in the sibling patches/ directory, so paths no longer traverse +# upward and no `--allowed-dir` flag is needed. `status` now works without it. patches: - - file: ../patches/cni-none.yaml - - file: ../patches/disable-kube-proxy.yaml - - file: ../patches/allow-scheduling.yaml - - file: ../patches/oci-lab-inline-manifests.yaml -controlPlane: - machineClass: - name: oci-cp - size: 1 -workers: - - machineClass: - name: oci-worker - size: 1 + - file: patches/cni-none.yaml + - file: patches/disable-kube-proxy.yaml + - file: patches/allow-scheduling.yaml + - file: patches/oci-lab-inline-manifests.yaml +--- +# omnictl requires ControlPlane and Workers as SEPARATE documents. They were +# nested under the Cluster document, which omnictl rejects outright: +# field controlPlane not found in type models.Cluster +# field workers not found in type models.Cluster +# So this template was never syncable in that form. +kind: ControlPlane +machineClass: + name: oci-cp + size: 1 +--- +kind: Workers +machineClass: + name: oci-worker + size: 1 diff --git a/omni/patches/allow-scheduling.yaml b/omni/cluster-templates/patches/allow-scheduling.yaml similarity index 100% rename from omni/patches/allow-scheduling.yaml rename to omni/cluster-templates/patches/allow-scheduling.yaml diff --git a/omni/patches/cni-none.yaml b/omni/cluster-templates/patches/cni-none.yaml similarity index 100% rename from omni/patches/cni-none.yaml rename to omni/cluster-templates/patches/cni-none.yaml diff --git a/omni/patches/disable-kube-proxy.yaml b/omni/cluster-templates/patches/disable-kube-proxy.yaml similarity index 100% rename from omni/patches/disable-kube-proxy.yaml rename to omni/cluster-templates/patches/disable-kube-proxy.yaml diff --git a/omni/patches/oci-lab-inline-manifests.yaml b/omni/cluster-templates/patches/oci-lab-inline-manifests.yaml similarity index 100% rename from omni/patches/oci-lab-inline-manifests.yaml rename to omni/cluster-templates/patches/oci-lab-inline-manifests.yaml diff --git a/omni/machine-classes/control-plane.yaml b/omni/machine-classes/control-plane.yaml index 01065cc..8f350d5 100644 --- a/omni/machine-classes/control-plane.yaml +++ b/omni/machine-classes/control-plane.yaml @@ -5,8 +5,15 @@ metadata: id: oci-cp spec: installImage: ghcr.io/syscode-labs/talos-images/installer:v1.13.7 - matchLabels: - # Synced into Omni; UI can select this class after machines join. - # Labels may be assigned by automation or, for lab recovery, in Omni UI. - # omnictl machine set-labels role=oci-cp - role: oci-cp + # Omni's MachineClass spec takes `matchlabels` (lowercase) as a LIST of + # label selector strings. It was written as a camelCase map, which Omni + # rejects with "machine class should either have auto provision or match + # labels set" — so these classes had never been applied and did not exist + # in Omni at all, which is why oci-lab could not allocate machines. + # + # Set the label on a machine with a MachineLabels resource: + # metadata: {namespace: default, type: MachineLabels.omni.sidero.dev, + # id: , labels: {role: oci-cp}} + # (`omnictl machine set-labels` does not exist in omnictl 1.9.3.) + matchlabels: + - role=oci-cp diff --git a/omni/machine-classes/worker.yaml b/omni/machine-classes/worker.yaml index 2626aad..55c6ae2 100644 --- a/omni/machine-classes/worker.yaml +++ b/omni/machine-classes/worker.yaml @@ -5,8 +5,15 @@ metadata: id: oci-worker spec: installImage: ghcr.io/syscode-labs/talos-images/installer:v1.13.7 - matchLabels: - # Synced into Omni; UI can select this class after machines join. - # Labels may be assigned by automation or, for lab recovery, in Omni UI. - # omnictl machine set-labels role=oci-worker - role: oci-worker + # Omni's MachineClass spec takes `matchlabels` (lowercase) as a LIST of + # label selector strings. It was written as a camelCase map, which Omni + # rejects with "machine class should either have auto provision or match + # labels set" — so these classes had never been applied and did not exist + # in Omni at all, which is why oci-lab could not allocate machines. + # + # Set the label on a machine with a MachineLabels resource: + # metadata: {namespace: default, type: MachineLabels.omni.sidero.dev, + # id: , labels: {role: oci-worker}} + # (`omnictl machine set-labels` does not exist in omnictl 1.9.3.) + matchlabels: + - role=oci-worker diff --git a/omni/scripts/generate-manifests.sh b/omni/scripts/generate-manifests.sh index 0c52671..a4e126b 100755 --- a/omni/scripts/generate-manifests.sh +++ b/omni/scripts/generate-manifests.sh @@ -23,7 +23,7 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" case "$CLUSTER" in oci-lab) CLUSTER_TYPE="cloud" - FILE="omni/patches/oci-lab-inline-manifests.yaml" + FILE="omni/cluster-templates/patches/oci-lab-inline-manifests.yaml" WITH_CILIUM=1 ;; unraid-lab) diff --git a/scripts/check-version-drift.py b/scripts/check-version-drift.py index 7380fb6..7010180 100644 --- a/scripts/check-version-drift.py +++ b/scripts/check-version-drift.py @@ -15,9 +15,13 @@ Runs identically locally and in CI — `python3 scripts/check-version-drift.py`. """ +import json import os import re +import ssl import sys +import urllib.error +import urllib.request import yaml @@ -33,6 +37,53 @@ ] +INSTALLER_IMAGE = "ghcr.io/syscode-labs/talos-images/installer" + + +def installer_built(tag): + """True if the talos-images installer image for this tag already exists in + ghcr.io. + + Machine-class installImage points at this image. The talos-images build + pushes a tag only for versions it has actually built. --write must never + point oci-lab at an installer tag that does not exist, or provisioning + would pull a nonexistent image. The build may legitimately be in flight; + use --allow-unbuilt to override (the talos-images job does, once it has + pushed the tag). + System Python versions (incl. macOS frameworks' 3.7) often lack a working + default SLL trust store; point at the platform CA bundle so the check runs + identically in CI (Ubuntu 3.11) and dev. + """ + repo = INSTALLER_IMAGE[len("ghcr.io/"):] + ctx = ssl.create_default_context() + for cafile in ("/etc/ssl/cert.pem", "/etc/ssl/certs/ca-certificates.crt"): + if os.path.exists(cafile): + ctx = ssl.create_default_context(cafile=cafile) + break + tok = urllib.request.urlopen( + "https://ghcr.io/token?scope=repository:%s:pull" % repo, + context=ctx, + ) + token = json.load(tok)["token"] + req = urllib.request.Request( + "https://ghcr.io/v2/%s/manifests/%s" % (repo, tag), + headers={ + "Authorization": "Bearer %s" % token, + "Accept": "application/vnd.docker.distribution.manifest.list.v2+json, " + "application/vnd.docker.distribution.manifest.v2+json, " + "application/vnd.oci.image.index.v1+json, " + "application/vnd.oci.image.manifest.v1+json", + }, + ) + try: + urllib.request.urlopen(req, context=ctx) + return True + except urllib.error.HTTPError as e: + if e.code == 404: + return False + raise + + def resolve(versions, cluster): """Resolve (talos, kubernetes) for a cluster. @@ -89,7 +140,8 @@ def main(): "Talos pin %s" % (rel, tag, talos) ) else: - doc = yaml.safe_load(text) or {} + docs = [d for d in yaml.safe_load_all(text) if d and d.get("kind") == "Cluster"] + doc = docs[0] if docs else {} got_talos = (doc.get("talos") or {}).get("version") got_k8s = (doc.get("kubernetes") or {}).get("version") if got_talos != talos: @@ -144,7 +196,105 @@ def selftest(): return 0 +def write(): + """Repair the derived, owned files from versions.yaml, the source of + truth. The counterpart to main(): where main() reports drift, --write + makes the derived files agree with versions.yaml so the gate goes green. + + Conservative by design: + * It only edits files this repo owns and that versions.yaml drives + (oci-lab's machine classes and cluster template). It never touches + omni/talos-image.yaml, a machine-owned build ledger. + * It refuses to write when oci-lab's Kubernetes version cannot be + resolved (a talos override with no explicit kubernetes pin), so it can + never invent a pairing that violates the Kubernetes-never-inherited + rule. + * It refuses to set machine-class installImage to an installer tag the + talos-images build has not yet pushed (see installer_built), so a + versions.yaml bump to a not-yet-built version cannot point oci-lab at + a nonexistent image. --allow-unbuilt overrides for the build itself. + """ + path = os.path.join(ROOT, "omni/versions.yaml") + with open(path) as fh: + versions = yaml.safe_load(fh) or {} + + if not versions.get("talos") or not versions.get("kubernetes"): + print("ERROR: omni/versions.yaml must set both talos and kubernetes") + return 1 + + talos, k8s = resolve(versions, "oci-lab") + if k8s is None: + print( + "ERROR: oci-lab overrides talos to %s with no kubernetes pin; " + "cannot write a pairing. Pin kubernetes in versions.yaml first." + % talos + ) + return 1 + + allow_unbuilt = "--allow-unbuilt" in sys.argv + if not allow_unbuilt and not installer_built(talos): + print( + "ERROR: installer image %s:%s has not been built yet. The " + "talos-images build must push it before oci-lab can install it. " + "Do not flip versions.yaml ahead of a build. " + "(--allow-unbuilt overrides.)" % (INSTALLER_IMAGE, talos) + ) + return 1 + + if _write(talos, k8s): + print( + "Rewrote derived files to match omni/versions.yaml " + "(oci-lab: Talos %s, Kubernetes %s)." % (talos, k8s) + ) + return 0 + print("No drift to repair; files already agree with omni/versions.yaml.") + return 0 + + +def _write(talos, k8s): + changed = False + for rel in OCI_LAB_FILES: + full = os.path.join(ROOT, rel) + if not os.path.exists(full): + continue + text = open(full).read() + new = text + + if "machine-classes" in rel: + # Swap the installer image tag (e.g. :v1.13.7) for the pinned Talos + # version, preserving the image path and any variant suffix that + # precedes the tag. + new = re.sub( + r"(\binstallImage:\s*\S+):(v\d[\w.-]*)", + lambda m: "%s:%s" % (m.group(1), talos), + new, + ) + else: + # Rewrite talos.version and kubernetes.version top-level blocks. + # Scope each to its own block so a boundary `version:` line in a + # nested document is never conflated with the one we mean. + new = re.sub( + r"(?m)^kubernetes:\s*\n(\s*version:)\s*\S+", + lambda m: "kubernetes:\n%s %s" % (m.group(1), k8s), + new, + ) + new = re.sub( + r"(?m)^talos:\s*\n(\s*version:)\s*\S+", + lambda m: "talos:\n%s %s" % (m.group(1), talos), + new, + ) + + if new != text: + with open(full, "w") as fh: + fh.write(new) + changed = True + print(" rewrote %s" % rel) + return changed + + if __name__ == "__main__": if "--selftest" in sys.argv: sys.exit(selftest()) + if "--write" in sys.argv: + sys.exit(write()) sys.exit(main()) diff --git a/values/clusters/oci-lab/cilium.yaml b/values/clusters/oci-lab/cilium.yaml new file mode 100644 index 0000000..c5116af --- /dev/null +++ b/values/clusters/oci-lab/cilium.yaml @@ -0,0 +1,8 @@ +hubble: + ui: + ingress: + hosts: + - hubble-oci-lab.wind-bearded.ts.net + tls: + - hosts: + - hubble-oci-lab.wind-bearded.ts.net diff --git a/values/clusters/unraid-lab/cilium.yaml b/values/clusters/unraid-lab/cilium.yaml index f367c6a..972b284 100644 --- a/values/clusters/unraid-lab/cilium.yaml +++ b/values/clusters/unraid-lab/cilium.yaml @@ -1,3 +1,9 @@ +# LAN VIP for opt-in LoadBalancer Services (openspec: add-metallb-lan-exposure). +# Reuses Cilium's native LB-IPAM + L2 announcement instead of a separate +# MetalLB install — same ARP mechanism, already running as the CNI. +l2announcements: + enabled: true + hubble: ui: ingress: