Localize the Tailscale operator stack (k8s-operator + indri ProxyClass) (#374)

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 <branch> && 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: #374
This commit is contained in:
Erich Blume 2026-06-09 17:45:23 -07:00
commit d03ed337a9
8 changed files with 307 additions and 12 deletions

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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"])

View file

@ -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 <nixpkgs> { } }:
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"
];
};
}

View file

@ -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"])
)

View file

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

View file

@ -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