Skip to content
22 changes: 22 additions & 0 deletions clusters/unraid-lab/apps/cilium-lan-vip/application.yaml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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"
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
- ipaddresspool.yaml
- l2announcementpolicy.yaml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 1 addition & 1 deletion docs/deploy/runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
```

Expand Down
33 changes: 21 additions & 12 deletions omni/cluster-templates/oci-lab.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 12 additions & 5 deletions omni/machine-classes/control-plane.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <machine-id> 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: <machine-uuid>, labels: {role: oci-cp}}
# (`omnictl machine set-labels` does not exist in omnictl 1.9.3.)
matchlabels:
- role=oci-cp
17 changes: 12 additions & 5 deletions omni/machine-classes/worker.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <machine-id> 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: <machine-uuid>, labels: {role: oci-worker}}
# (`omnictl machine set-labels` does not exist in omnictl 1.9.3.)
matchlabels:
- role=oci-worker
2 changes: 1 addition & 1 deletion omni/scripts/generate-manifests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
152 changes: 151 additions & 1 deletion scripts/check-version-drift.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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())
8 changes: 8 additions & 0 deletions values/clusters/oci-lab/cilium.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
hubble:
ui:
ingress:
hosts:
- hubble-oci-lab.wind-bearded.ts.net
tls:
- hosts:
- hubble-oci-lab.wind-bearded.ts.net
6 changes: 6 additions & 0 deletions values/clusters/unraid-lab/cilium.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
Loading