From ac40a18f3fba04cf4a51dc8ce1f67c32de3eb123 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 9 Jun 2026 16:39:38 -0700 Subject: [PATCH 1/4] Localize tailscale operator stack: docs + container builds Docs-first for C1: tailscale-operator card gains Local Images and Rollout Safety sections (device identity lives in state Secrets; image swaps don't re-register devices). New containers/tailscale-operator (container.py for indri/arm64, default.nix for ringtail/amd64) builds cmd/k8s-operator from the forge mirror, mirroring upstream's mkctr recipe. containers/tailscale gains a container.py so indri's ProxyClass can use a local arm64 proxy image (ringtail already consumes the nix build). Manifest updates follow once images are built and tagged. Co-Authored-By: Claude Fable 5 --- containers/tailscale-operator/container.py | 53 +++++++++ containers/tailscale-operator/default.nix | 67 +++++++++++ containers/tailscale/container.py | 104 ++++++++++++++++++ .../localize-tailscale-operator.infra.md | 1 + .../kubernetes/tailscale-operator.md | 44 +++++++- 5 files changed, 263 insertions(+), 6 deletions(-) create mode 100644 containers/tailscale-operator/container.py create mode 100644 containers/tailscale-operator/default.nix create mode 100644 containers/tailscale/container.py create mode 100644 docs/changelog.d/localize-tailscale-operator.infra.md diff --git a/containers/tailscale-operator/container.py b/containers/tailscale-operator/container.py new file mode 100644 index 0000000..ff63845 --- /dev/null +++ b/containers/tailscale-operator/container.py @@ -0,0 +1,53 @@ +"""Tailscale Kubernetes operator — native Dagger build. + +Single Go binary (cmd/k8s-operator) from the forge mirror, mirroring +upstream's build_docker.sh mkctr recipe: binary at /usr/local/bin/operator, +go tags ts_kube + ts_package_container, version stamps in ldflags. + +Consumed by the tailscale-operator app on indri's minikube (arm64); the +ringtail app uses the -nix tag from default.nix instead. +""" + +import dagger + +from blumeops.containers import ( + alpine_runtime, + clone_from_forge, + go_build, + oci_labels, +) + +VERSION = "v1.94.2" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("tailscale", VERSION) + semver = VERSION.removeprefix("v") + + builder = go_build( + source, + "/out/operator", + cmd_path="./cmd/k8s-operator", + tags="ts_kube,ts_package_container", + ldflags=( + "-w -s" + f" -X tailscale.com/version.longStamp={semver}" + f" -X tailscale.com/version.shortStamp={semver}" + ), + ) + + # Upstream runs the operator as root on a minimal base; only CA certs + # are needed at runtime (operator talks to the k8s API and Tailscale + # control plane over HTTPS). + runtime = alpine_runtime(extra_apk=["ca-certificates"], create_user=False) + runtime = oci_labels( + runtime, + title="Tailscale Kubernetes Operator", + description="Tailscale operator for Kubernetes Ingress/egress proxies", + version=VERSION, + ) + return runtime.with_file( + "/usr/local/bin/operator", + builder.file("/out/operator"), + permissions=0o555, + ).with_entrypoint(["/usr/local/bin/operator"]) diff --git a/containers/tailscale-operator/default.nix b/containers/tailscale-operator/default.nix new file mode 100644 index 0000000..8b279d5 --- /dev/null +++ b/containers/tailscale-operator/default.nix @@ -0,0 +1,67 @@ +# Nix-built tailscale k8s-operator for ringtail's tailscale-operator app. +# Builds cmd/k8s-operator v1.94.2 from the forge mirror, mirroring upstream's +# build_docker.sh mkctr recipe (binary at /usr/local/bin/operator, ts_kube + +# ts_package_container go tags). Built on the ringtail nix-container-builder. +{ pkgs ? import { } }: + +let + version = "1.94.2"; + + src = pkgs.fetchgit { + url = "https://forge.ops.eblu.me/mirrors/tailscale.git"; + rev = "v${version}"; + hash = "sha256-qjWVB8xWVgIVUgrf27F6hwiFIE+4ERXWeHv26ugg/x4="; + }; + + operator = pkgs.buildGoModule { + inherit src version; + pname = "tailscale-operator"; + vendorHash = "sha256-WeMTOkERj4hvdg4yPaZ1gRgKnhRIBXX55kUVbX/k/xM="; + + subPackages = [ "cmd/k8s-operator" ]; + + tags = [ + "ts_kube" + "ts_package_container" + ]; + + ldflags = [ + "-s" + "-w" + "-X tailscale.com/version.longStamp=${version}" + "-X tailscale.com/version.shortStamp=${version}" + ]; + + doCheck = false; + + meta = with pkgs.lib; { + description = "Tailscale operator for Kubernetes"; + homepage = "https://tailscale.com"; + license = licenses.bsd3; + }; + }; +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/tailscale-operator"; + tag = "v${version}"; + + contents = [ + operator + pkgs.cacert + ]; + + # buildGoModule names the binary after the package dir (k8s-operator); + # upstream's image expects /usr/local/bin/operator. + extraCommands = '' + mkdir -p usr/local/bin + ln -s /bin/k8s-operator usr/local/bin/operator + ''; + + config = { + Entrypoint = [ "/usr/local/bin/operator" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + ]; + }; +} diff --git a/containers/tailscale/container.py b/containers/tailscale/container.py new file mode 100644 index 0000000..8e3e509 --- /dev/null +++ b/containers/tailscale/container.py @@ -0,0 +1,104 @@ +"""Tailscale proxy image (containerboot) — native Dagger build. + +Builds cmd/tailscale, cmd/tailscaled, and cmd/containerboot from the forge +mirror, mirroring the upstream Dockerfile: Alpine runtime with iptables +(legacy symlinked over the default, per upstream issue #17854), iproute2, +and the /tailscale/run.sh compat symlink. + +Consumed by the tailscale-operator ProxyClass on indri's minikube (arm64); +ringtail's ProxyClass uses the -nix tag from default.nix instead. +""" + +import dagger + +from blumeops.containers import ( + alpine_runtime, + clone_from_forge, + go_build, + oci_labels, +) + +VERSION = "v1.94.2" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("tailscale", VERSION) + semver = VERSION.removeprefix("v") + + ldflags = ( + "-w -s" + f" -X tailscale.com/version.longStamp={semver}" + f" -X tailscale.com/version.shortStamp={semver}" + ) + builder = go_build( + source, + "/out/tailscale", + cmd_path="./cmd/tailscale", + ldflags=ldflags, + ) + builder = builder.with_exec( + [ + "go", + "build", + f"-ldflags={ldflags}", + "-o", + "/out/tailscaled", + "./cmd/tailscaled", + ] + ).with_exec( + [ + "go", + "build", + f"-ldflags={ldflags}", + "-o", + "/out/containerboot", + "./cmd/containerboot", + ] + ) + + runtime = alpine_runtime( + extra_apk=["ca-certificates", "iptables", "iproute2", "ip6tables"], + create_user=False, + ) + runtime = oci_labels( + runtime, + title="Tailscale", + description="Tailscale containerboot proxy image for the k8s operator", + version=VERSION, + ) + return ( + runtime + # Match upstream Dockerfile: nftables-backed iptables misbehaves in + # some environments, force the legacy backend (tailscale/tailscale#17854). + .with_exec( + [ + "sh", + "-c", + "rm /usr/sbin/iptables && ln -s /usr/sbin/iptables-legacy /usr/sbin/iptables" + " && rm /usr/sbin/ip6tables && ln -s /usr/sbin/ip6tables-legacy /usr/sbin/ip6tables", + ] + ) + .with_file( + "/usr/local/bin/tailscale", + builder.file("/out/tailscale"), + permissions=0o555, + ) + .with_file( + "/usr/local/bin/tailscaled", + builder.file("/out/tailscaled"), + permissions=0o555, + ) + .with_file( + "/usr/local/bin/containerboot", + builder.file("/out/containerboot"), + permissions=0o555, + ) + .with_exec( + [ + "sh", + "-c", + "mkdir /tailscale && ln -s /usr/local/bin/containerboot /tailscale/run.sh", + ] + ) + .with_entrypoint(["/usr/local/bin/containerboot"]) + ) diff --git a/docs/changelog.d/localize-tailscale-operator.infra.md b/docs/changelog.d/localize-tailscale-operator.infra.md new file mode 100644 index 0000000..324eac6 --- /dev/null +++ b/docs/changelog.d/localize-tailscale-operator.infra.md @@ -0,0 +1 @@ +Localized the Tailscale operator stack: the k8s-operator image (both clusters) and the ProxyClass proxy image (indri, completing parity with ringtail) are now built from the forge mirror instead of pulled from Docker Hub. diff --git a/docs/reference/kubernetes/tailscale-operator.md b/docs/reference/kubernetes/tailscale-operator.md index 174b347..ba03014 100644 --- a/docs/reference/kubernetes/tailscale-operator.md +++ b/docs/reference/kubernetes/tailscale-operator.md @@ -1,7 +1,7 @@ --- title: Tailscale Operator -modified: 2026-06-08 -last-reviewed: 2026-06-08 +modified: 2026-06-09 +last-reviewed: 2026-06-09 tags: - kubernetes - tailscale @@ -22,10 +22,42 @@ The Tailscale operator enables Kubernetes services to be exposed directly on the The operator runs on **both** clusters — indri's minikube and ringtail's k3s. Both apps layer on the shared `tailscale-operator-base` kustomize directory (operator manifest, `ProxyClass`, `dnsconfig`); each cluster supplies its own -`ProxyGroup` (indri: 2 replicas, ringtail: 1) and OAuth `ExternalSecret`. The -ringtail overlay additionally rewrites the proxy image to a locally nix-built -mirror. See [[ringtail]] and [[migrate-wave1-ringtail]] for the ongoing -migration of k8s workloads onto ringtail. +`ProxyGroup` (indri: 2 replicas, ringtail: 1) and OAuth `ExternalSecret`. See +[[ringtail]] and [[migrate-wave1-ringtail]] for the ongoing migration of k8s +workloads onto ringtail. + +## Local Images + +Both the operator and the proxy run locally-built images from the forge +mirror (`mirrors/tailscale`), not Docker Hub: + +| Image | Build | Used by | +|-------|-------|---------| +| `blumeops/tailscale-operator` | `containers/tailscale-operator/` (`container.py` for indri/arm64, `default.nix` `-nix` tag for ringtail/amd64) | operator Deployment, via each overlay's `images:` override | +| `blumeops/tailscale` | `containers/tailscale/` (same dual build) | `ProxyClass` proxy pods, via a strategic-merge patch in each overlay | + +The ProxyClass image must be set with a **patch**, not kustomize's `images:` +directive — that directive only rewrites standard container fields, not +custom-resource fields like `ProxyClass.spec.statefulSet.pod.tailscaleContainer.image`. + +The `dnsconfig` nameserver image (`tailscale/k8s-nameserver:stable`) is still +upstream — a known follow-up. + +## Rollout Safety (device identity) + +Proxy and operator tailnet identity lives in Kubernetes state Secrets in the +`tailscale` namespace, not in pods or images. An image swap rolls the +Deployment/StatefulSets but pods re-authenticate with their existing node +keys — devices keep their names. Shadow devices (`foo-1` suffixes) appear only +when a pod registers *fresh* while a stale device record still holds the name +(deleted state Secrets, cluster rebuilds). When rolling out image changes: + +1. Never delete the `tailscale` namespace state Secrets. +2. Verify after sync: pods healthy, device names unchanged in the admin + console, `mise run services-check` green. +3. If a collision does occur: delete the stale device in the admin console + AND the affected state Secret, then restart the pod (see + [[rebuild-minikube-cluster]]). ## How It Works From af0fce2a05fca6edf6feb07d79b535054b0e0025 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 9 Jun 2026 16:56:43 -0700 Subject: [PATCH 2/4] Point tailscale-operator manifests at local images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit indri overlay: operator images: override (dagger/arm64 tag) + ProxyClass strategic-merge patch for the proxy image (kustomize images: cannot rewrite CR fields). ringtail overlay: operator images: override (-nix tag); its proxy image is already local and unchanged. Both overlays validated with kubectl kustomize. Images built from this branch (runs 583/584); same v1.94.2 as currently deployed — pure supply-chain swap. Co-Authored-By: Claude Fable 5 --- .../kustomization.yaml | 19 ++++++++++++------ .../tailscale-operator/kustomization.yaml | 20 +++++++++++++++++++ .../tailscale-operator/proxyclass-image.yaml | 11 ++++++++++ 3 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 argocd/manifests/tailscale-operator/proxyclass-image.yaml diff --git a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml b/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml index 2d9ceb2..fc119c9 100644 --- a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml +++ b/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml @@ -9,12 +9,19 @@ resources: - proxygroup-ingress.yaml - external-secret.yaml -# Rewrite the proxyclass image to our local nix-built mirror. -# Scoped to ringtail only; indri's tailscale-operator/kustomization.yaml still -# pulls from upstream docker.io. A strategic merge patch is used instead of -# kustomize's `images:` directive because that directive only rewrites images -# in standard k8s container fields, not custom-resource fields like -# ProxyClass.spec.statefulSet.pod.tailscaleContainer.image. +# Rewrite the operator image to the locally nix-built (amd64) mirror. +# The name must match the post-base-render image (base already rewrites +# tailscale/k8s-operator -> docker.io/tailscale/k8s-operator). +images: + - name: docker.io/tailscale/k8s-operator + newName: registry.ops.eblu.me/blumeops/tailscale-operator + newTag: v1.94.2-ac40a18-nix + +# Rewrite the proxyclass image to our local nix-built mirror (indri's overlay +# carries the equivalent dagger/arm64 patch). A strategic merge patch is used +# instead of kustomize's `images:` directive because that directive only +# rewrites images in standard k8s container fields, not custom-resource fields +# like ProxyClass.spec.statefulSet.pod.tailscaleContainer.image. patches: - path: proxyclass-image.yaml target: diff --git a/argocd/manifests/tailscale-operator/kustomization.yaml b/argocd/manifests/tailscale-operator/kustomization.yaml index f1d6f89..ad275a9 100644 --- a/argocd/manifests/tailscale-operator/kustomization.yaml +++ b/argocd/manifests/tailscale-operator/kustomization.yaml @@ -14,3 +14,23 @@ resources: # Endpoints). Apply manually: # kubectl --context=minikube-indri apply -f endpoints-forge.yaml - ingress-forge.yaml + +# Rewrite the operator image to the locally dagger-built (arm64) mirror. +# The name must match the post-base-render image (base already rewrites +# tailscale/k8s-operator -> docker.io/tailscale/k8s-operator). +images: + - name: docker.io/tailscale/k8s-operator + newName: registry.ops.eblu.me/blumeops/tailscale-operator + newTag: v1.94.2-ac40a18 + +# Rewrite the proxyclass image to the local mirror. A strategic merge patch +# is used instead of kustomize's `images:` directive because that directive +# only rewrites standard k8s container fields, not custom-resource fields +# like ProxyClass.spec.statefulSet.pod.tailscaleContainer.image. +patches: + - path: proxyclass-image.yaml + target: + group: tailscale.com + version: v1alpha1 + kind: ProxyClass + name: default diff --git a/argocd/manifests/tailscale-operator/proxyclass-image.yaml b/argocd/manifests/tailscale-operator/proxyclass-image.yaml new file mode 100644 index 0000000..eae73eb --- /dev/null +++ b/argocd/manifests/tailscale-operator/proxyclass-image.yaml @@ -0,0 +1,11 @@ +apiVersion: tailscale.com/v1alpha1 +kind: ProxyClass +metadata: + name: default +spec: + statefulSet: + pod: + tailscaleContainer: + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-ac40a18 + tailscaleInitContainer: + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-ac40a18 From d03ed337a9dfb4dc3cafb658d2cb3fccc193c797 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 9 Jun 2026 17:45:23 -0700 Subject: [PATCH 3/4] Localize the Tailscale operator stack (k8s-operator + indri ProxyClass) (#374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Weekly non-local-container task: localize the Tailscale operator stack on **both clusters**. ## What - **`containers/tailscale-operator/`** (new) — builds `cmd/k8s-operator` v1.94.2 from the forge mirror, mirroring upstream's mkctr recipe (`/usr/local/bin/operator`, `ts_kube,ts_package_container` go tags, version stamps). `container.py` (dagger) for indri/arm64; `default.nix` for ringtail/amd64. - **`containers/tailscale/container.py`** (new) — dagger/arm64 build of the proxy image (containerboot), mirroring the upstream Dockerfile (iptables-legacy symlinks, `/tailscale/run.sh` compat). Ringtail already consumes the existing nix build; this completes parity for indri. - **Version pinned at v1.94.2** (same as currently deployed) — this PR is a pure supply-chain swap, no version change. v1.96.x is avoided deliberately (MagicDNS-in-containers regression). - Docs-first: tailscale-operator card gains **Local Images** and **Rollout Safety** sections. ## Rollout plan (after image builds) 1. Manifest commit: per-overlay `images:` override for the operator + ProxyClass strategic-merge patch on indri (kustomize `images:` can't touch CR fields). 2. `argocd app set tailscale-operator --revision && argocd app sync` — indri first, verify, then ringtail. 3. **Shadow-device safety**: device identity lives in the tailscale state Secrets; an image swap re-uses existing node keys, so no `-1` clones. State Secrets are not touched. Post-sync verification: pod health, device names unchanged, `mise run services-check`. ## Follow-ups (not this PR) - `dnsconfig` nameserver image (`tailscale/k8s-nameserver:stable`) still upstream. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Reviewed-on: https://forge.eblu.me/eblume/blumeops/pulls/374 --- .../kustomization.yaml | 19 +++- .../tailscale-operator/kustomization.yaml | 20 ++++ .../tailscale-operator/proxyclass-image.yaml | 11 ++ containers/tailscale-operator/container.py | 53 +++++++++ containers/tailscale-operator/default.nix | 67 +++++++++++ containers/tailscale/container.py | 104 ++++++++++++++++++ .../localize-tailscale-operator.infra.md | 1 + .../kubernetes/tailscale-operator.md | 44 +++++++- 8 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 argocd/manifests/tailscale-operator/proxyclass-image.yaml create mode 100644 containers/tailscale-operator/container.py create mode 100644 containers/tailscale-operator/default.nix create mode 100644 containers/tailscale/container.py create mode 100644 docs/changelog.d/localize-tailscale-operator.infra.md diff --git a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml b/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml index 2d9ceb2..fc119c9 100644 --- a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml +++ b/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml @@ -9,12 +9,19 @@ resources: - proxygroup-ingress.yaml - external-secret.yaml -# Rewrite the proxyclass image to our local nix-built mirror. -# Scoped to ringtail only; indri's tailscale-operator/kustomization.yaml still -# pulls from upstream docker.io. A strategic merge patch is used instead of -# kustomize's `images:` directive because that directive only rewrites images -# in standard k8s container fields, not custom-resource fields like -# ProxyClass.spec.statefulSet.pod.tailscaleContainer.image. +# Rewrite the operator image to the locally nix-built (amd64) mirror. +# The name must match the post-base-render image (base already rewrites +# tailscale/k8s-operator -> docker.io/tailscale/k8s-operator). +images: + - name: docker.io/tailscale/k8s-operator + newName: registry.ops.eblu.me/blumeops/tailscale-operator + newTag: v1.94.2-ac40a18-nix + +# Rewrite the proxyclass image to our local nix-built mirror (indri's overlay +# carries the equivalent dagger/arm64 patch). A strategic merge patch is used +# instead of kustomize's `images:` directive because that directive only +# rewrites images in standard k8s container fields, not custom-resource fields +# like ProxyClass.spec.statefulSet.pod.tailscaleContainer.image. patches: - path: proxyclass-image.yaml target: diff --git a/argocd/manifests/tailscale-operator/kustomization.yaml b/argocd/manifests/tailscale-operator/kustomization.yaml index f1d6f89..ad275a9 100644 --- a/argocd/manifests/tailscale-operator/kustomization.yaml +++ b/argocd/manifests/tailscale-operator/kustomization.yaml @@ -14,3 +14,23 @@ resources: # Endpoints). Apply manually: # kubectl --context=minikube-indri apply -f endpoints-forge.yaml - ingress-forge.yaml + +# Rewrite the operator image to the locally dagger-built (arm64) mirror. +# The name must match the post-base-render image (base already rewrites +# tailscale/k8s-operator -> docker.io/tailscale/k8s-operator). +images: + - name: docker.io/tailscale/k8s-operator + newName: registry.ops.eblu.me/blumeops/tailscale-operator + newTag: v1.94.2-ac40a18 + +# Rewrite the proxyclass image to the local mirror. A strategic merge patch +# is used instead of kustomize's `images:` directive because that directive +# only rewrites standard k8s container fields, not custom-resource fields +# like ProxyClass.spec.statefulSet.pod.tailscaleContainer.image. +patches: + - path: proxyclass-image.yaml + target: + group: tailscale.com + version: v1alpha1 + kind: ProxyClass + name: default diff --git a/argocd/manifests/tailscale-operator/proxyclass-image.yaml b/argocd/manifests/tailscale-operator/proxyclass-image.yaml new file mode 100644 index 0000000..eae73eb --- /dev/null +++ b/argocd/manifests/tailscale-operator/proxyclass-image.yaml @@ -0,0 +1,11 @@ +apiVersion: tailscale.com/v1alpha1 +kind: ProxyClass +metadata: + name: default +spec: + statefulSet: + pod: + tailscaleContainer: + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-ac40a18 + tailscaleInitContainer: + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-ac40a18 diff --git a/containers/tailscale-operator/container.py b/containers/tailscale-operator/container.py new file mode 100644 index 0000000..ff63845 --- /dev/null +++ b/containers/tailscale-operator/container.py @@ -0,0 +1,53 @@ +"""Tailscale Kubernetes operator — native Dagger build. + +Single Go binary (cmd/k8s-operator) from the forge mirror, mirroring +upstream's build_docker.sh mkctr recipe: binary at /usr/local/bin/operator, +go tags ts_kube + ts_package_container, version stamps in ldflags. + +Consumed by the tailscale-operator app on indri's minikube (arm64); the +ringtail app uses the -nix tag from default.nix instead. +""" + +import dagger + +from blumeops.containers import ( + alpine_runtime, + clone_from_forge, + go_build, + oci_labels, +) + +VERSION = "v1.94.2" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("tailscale", VERSION) + semver = VERSION.removeprefix("v") + + builder = go_build( + source, + "/out/operator", + cmd_path="./cmd/k8s-operator", + tags="ts_kube,ts_package_container", + ldflags=( + "-w -s" + f" -X tailscale.com/version.longStamp={semver}" + f" -X tailscale.com/version.shortStamp={semver}" + ), + ) + + # Upstream runs the operator as root on a minimal base; only CA certs + # are needed at runtime (operator talks to the k8s API and Tailscale + # control plane over HTTPS). + runtime = alpine_runtime(extra_apk=["ca-certificates"], create_user=False) + runtime = oci_labels( + runtime, + title="Tailscale Kubernetes Operator", + description="Tailscale operator for Kubernetes Ingress/egress proxies", + version=VERSION, + ) + return runtime.with_file( + "/usr/local/bin/operator", + builder.file("/out/operator"), + permissions=0o555, + ).with_entrypoint(["/usr/local/bin/operator"]) diff --git a/containers/tailscale-operator/default.nix b/containers/tailscale-operator/default.nix new file mode 100644 index 0000000..8b279d5 --- /dev/null +++ b/containers/tailscale-operator/default.nix @@ -0,0 +1,67 @@ +# Nix-built tailscale k8s-operator for ringtail's tailscale-operator app. +# Builds cmd/k8s-operator v1.94.2 from the forge mirror, mirroring upstream's +# build_docker.sh mkctr recipe (binary at /usr/local/bin/operator, ts_kube + +# ts_package_container go tags). Built on the ringtail nix-container-builder. +{ pkgs ? import { } }: + +let + version = "1.94.2"; + + src = pkgs.fetchgit { + url = "https://forge.ops.eblu.me/mirrors/tailscale.git"; + rev = "v${version}"; + hash = "sha256-qjWVB8xWVgIVUgrf27F6hwiFIE+4ERXWeHv26ugg/x4="; + }; + + operator = pkgs.buildGoModule { + inherit src version; + pname = "tailscale-operator"; + vendorHash = "sha256-WeMTOkERj4hvdg4yPaZ1gRgKnhRIBXX55kUVbX/k/xM="; + + subPackages = [ "cmd/k8s-operator" ]; + + tags = [ + "ts_kube" + "ts_package_container" + ]; + + ldflags = [ + "-s" + "-w" + "-X tailscale.com/version.longStamp=${version}" + "-X tailscale.com/version.shortStamp=${version}" + ]; + + doCheck = false; + + meta = with pkgs.lib; { + description = "Tailscale operator for Kubernetes"; + homepage = "https://tailscale.com"; + license = licenses.bsd3; + }; + }; +in + +pkgs.dockerTools.buildLayeredImage { + name = "blumeops/tailscale-operator"; + tag = "v${version}"; + + contents = [ + operator + pkgs.cacert + ]; + + # buildGoModule names the binary after the package dir (k8s-operator); + # upstream's image expects /usr/local/bin/operator. + extraCommands = '' + mkdir -p usr/local/bin + ln -s /bin/k8s-operator usr/local/bin/operator + ''; + + config = { + Entrypoint = [ "/usr/local/bin/operator" ]; + Env = [ + "SSL_CERT_FILE=${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + ]; + }; +} diff --git a/containers/tailscale/container.py b/containers/tailscale/container.py new file mode 100644 index 0000000..8e3e509 --- /dev/null +++ b/containers/tailscale/container.py @@ -0,0 +1,104 @@ +"""Tailscale proxy image (containerboot) — native Dagger build. + +Builds cmd/tailscale, cmd/tailscaled, and cmd/containerboot from the forge +mirror, mirroring the upstream Dockerfile: Alpine runtime with iptables +(legacy symlinked over the default, per upstream issue #17854), iproute2, +and the /tailscale/run.sh compat symlink. + +Consumed by the tailscale-operator ProxyClass on indri's minikube (arm64); +ringtail's ProxyClass uses the -nix tag from default.nix instead. +""" + +import dagger + +from blumeops.containers import ( + alpine_runtime, + clone_from_forge, + go_build, + oci_labels, +) + +VERSION = "v1.94.2" + + +async def build(src: dagger.Directory) -> dagger.Container: + source = clone_from_forge("tailscale", VERSION) + semver = VERSION.removeprefix("v") + + ldflags = ( + "-w -s" + f" -X tailscale.com/version.longStamp={semver}" + f" -X tailscale.com/version.shortStamp={semver}" + ) + builder = go_build( + source, + "/out/tailscale", + cmd_path="./cmd/tailscale", + ldflags=ldflags, + ) + builder = builder.with_exec( + [ + "go", + "build", + f"-ldflags={ldflags}", + "-o", + "/out/tailscaled", + "./cmd/tailscaled", + ] + ).with_exec( + [ + "go", + "build", + f"-ldflags={ldflags}", + "-o", + "/out/containerboot", + "./cmd/containerboot", + ] + ) + + runtime = alpine_runtime( + extra_apk=["ca-certificates", "iptables", "iproute2", "ip6tables"], + create_user=False, + ) + runtime = oci_labels( + runtime, + title="Tailscale", + description="Tailscale containerboot proxy image for the k8s operator", + version=VERSION, + ) + return ( + runtime + # Match upstream Dockerfile: nftables-backed iptables misbehaves in + # some environments, force the legacy backend (tailscale/tailscale#17854). + .with_exec( + [ + "sh", + "-c", + "rm /usr/sbin/iptables && ln -s /usr/sbin/iptables-legacy /usr/sbin/iptables" + " && rm /usr/sbin/ip6tables && ln -s /usr/sbin/ip6tables-legacy /usr/sbin/ip6tables", + ] + ) + .with_file( + "/usr/local/bin/tailscale", + builder.file("/out/tailscale"), + permissions=0o555, + ) + .with_file( + "/usr/local/bin/tailscaled", + builder.file("/out/tailscaled"), + permissions=0o555, + ) + .with_file( + "/usr/local/bin/containerboot", + builder.file("/out/containerboot"), + permissions=0o555, + ) + .with_exec( + [ + "sh", + "-c", + "mkdir /tailscale && ln -s /usr/local/bin/containerboot /tailscale/run.sh", + ] + ) + .with_entrypoint(["/usr/local/bin/containerboot"]) + ) diff --git a/docs/changelog.d/localize-tailscale-operator.infra.md b/docs/changelog.d/localize-tailscale-operator.infra.md new file mode 100644 index 0000000..324eac6 --- /dev/null +++ b/docs/changelog.d/localize-tailscale-operator.infra.md @@ -0,0 +1 @@ +Localized the Tailscale operator stack: the k8s-operator image (both clusters) and the ProxyClass proxy image (indri, completing parity with ringtail) are now built from the forge mirror instead of pulled from Docker Hub. diff --git a/docs/reference/kubernetes/tailscale-operator.md b/docs/reference/kubernetes/tailscale-operator.md index 174b347..ba03014 100644 --- a/docs/reference/kubernetes/tailscale-operator.md +++ b/docs/reference/kubernetes/tailscale-operator.md @@ -1,7 +1,7 @@ --- title: Tailscale Operator -modified: 2026-06-08 -last-reviewed: 2026-06-08 +modified: 2026-06-09 +last-reviewed: 2026-06-09 tags: - kubernetes - tailscale @@ -22,10 +22,42 @@ The Tailscale operator enables Kubernetes services to be exposed directly on the The operator runs on **both** clusters — indri's minikube and ringtail's k3s. Both apps layer on the shared `tailscale-operator-base` kustomize directory (operator manifest, `ProxyClass`, `dnsconfig`); each cluster supplies its own -`ProxyGroup` (indri: 2 replicas, ringtail: 1) and OAuth `ExternalSecret`. The -ringtail overlay additionally rewrites the proxy image to a locally nix-built -mirror. See [[ringtail]] and [[migrate-wave1-ringtail]] for the ongoing -migration of k8s workloads onto ringtail. +`ProxyGroup` (indri: 2 replicas, ringtail: 1) and OAuth `ExternalSecret`. See +[[ringtail]] and [[migrate-wave1-ringtail]] for the ongoing migration of k8s +workloads onto ringtail. + +## Local Images + +Both the operator and the proxy run locally-built images from the forge +mirror (`mirrors/tailscale`), not Docker Hub: + +| Image | Build | Used by | +|-------|-------|---------| +| `blumeops/tailscale-operator` | `containers/tailscale-operator/` (`container.py` for indri/arm64, `default.nix` `-nix` tag for ringtail/amd64) | operator Deployment, via each overlay's `images:` override | +| `blumeops/tailscale` | `containers/tailscale/` (same dual build) | `ProxyClass` proxy pods, via a strategic-merge patch in each overlay | + +The ProxyClass image must be set with a **patch**, not kustomize's `images:` +directive — that directive only rewrites standard container fields, not +custom-resource fields like `ProxyClass.spec.statefulSet.pod.tailscaleContainer.image`. + +The `dnsconfig` nameserver image (`tailscale/k8s-nameserver:stable`) is still +upstream — a known follow-up. + +## Rollout Safety (device identity) + +Proxy and operator tailnet identity lives in Kubernetes state Secrets in the +`tailscale` namespace, not in pods or images. An image swap rolls the +Deployment/StatefulSets but pods re-authenticate with their existing node +keys — devices keep their names. Shadow devices (`foo-1` suffixes) appear only +when a pod registers *fresh* while a stale device record still holds the name +(deleted state Secrets, cluster rebuilds). When rolling out image changes: + +1. Never delete the `tailscale` namespace state Secrets. +2. Verify after sync: pods healthy, device names unchanged in the admin + console, `mise run services-check` green. +3. If a collision does occur: delete the stale device in the admin console + AND the affected state Secret, then restart the pod (see + [[rebuild-minikube-cluster]]). ## How It Works From 7581b61cbf2da69b06c38396d8967e7dcd903c08 Mon Sep 17 00:00:00 2001 From: Erich Blume Date: Tue, 9 Jun 2026 17:55:52 -0700 Subject: [PATCH 4/4] C0: tailscale-operator manifests to [main] image tags Post-squash-merge rebuild of PR #374's containers from main (runs 585/586); same v1.94.2 content, tags now traceable to d03ed337. Co-Authored-By: Claude Fable 5 --- .../manifests/tailscale-operator-ringtail/kustomization.yaml | 2 +- argocd/manifests/tailscale-operator/kustomization.yaml | 2 +- argocd/manifests/tailscale-operator/proxyclass-image.yaml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml b/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml index fc119c9..25c3545 100644 --- a/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml +++ b/argocd/manifests/tailscale-operator-ringtail/kustomization.yaml @@ -15,7 +15,7 @@ resources: images: - name: docker.io/tailscale/k8s-operator newName: registry.ops.eblu.me/blumeops/tailscale-operator - newTag: v1.94.2-ac40a18-nix + newTag: v1.94.2-d03ed33-nix # Rewrite the proxyclass image to our local nix-built mirror (indri's overlay # carries the equivalent dagger/arm64 patch). A strategic merge patch is used diff --git a/argocd/manifests/tailscale-operator/kustomization.yaml b/argocd/manifests/tailscale-operator/kustomization.yaml index ad275a9..239f7ea 100644 --- a/argocd/manifests/tailscale-operator/kustomization.yaml +++ b/argocd/manifests/tailscale-operator/kustomization.yaml @@ -21,7 +21,7 @@ resources: images: - name: docker.io/tailscale/k8s-operator newName: registry.ops.eblu.me/blumeops/tailscale-operator - newTag: v1.94.2-ac40a18 + newTag: v1.94.2-d03ed33 # Rewrite the proxyclass image to the local mirror. A strategic merge patch # is used instead of kustomize's `images:` directive because that directive diff --git a/argocd/manifests/tailscale-operator/proxyclass-image.yaml b/argocd/manifests/tailscale-operator/proxyclass-image.yaml index eae73eb..82a7e0b 100644 --- a/argocd/manifests/tailscale-operator/proxyclass-image.yaml +++ b/argocd/manifests/tailscale-operator/proxyclass-image.yaml @@ -6,6 +6,6 @@ spec: statefulSet: pod: tailscaleContainer: - image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-ac40a18 + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-d03ed33 tailscaleInitContainer: - image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-ac40a18 + image: registry.ops.eblu.me/blumeops/tailscale:v1.94.2-d03ed33