Files
cozystack/hack/e2e-talos-image-cache.yaml
Myasnikov DaniilandClaude Opus 4.8 ff0d0d77a7 test(e2e): make the talos image cache reachable by tenant workers (#3254)
The in-sandbox Talos image mirror (#3244) is unreachable by the tenant
workers it serves, so the kubernetes-* e2e tests fail deterministically at
the 12m node-join wait instead of relieving the public-factory flake (#3231).

Root cause (from #3254 by @lexfrei, verified against the failing run's
crust-gather snapshot): tenant namespaces run a default-deny Cilium egress
(allow-internal/external-communication + the <tenant>-egress CCNP) that
permits the world entity and the tenant tree but NOT an arbitrary kube-system
Service, so a worker's CDI importer resolves talos-image-cache.kube-system.svc
yet its TCP connect to the ClusterIP is silently dropped and no disk imports.
#3244's gate never caught this because it probed the server over localhost
inside the serve pod, which passes regardless of cross-pod reachability.

Port of #3254 to the Chainsaw layout (hack/e2e-chainsaw/_lib/):
- ship a tightly-scoped CiliumClusterwideNetworkPolicy (importer pods in
  tenant-test -> mirror pod in kube-system), applied at point-of-use once
  Cilium CRDs exist; excluded from the pre-Cilium install apply via yq.
- replace the readiness probe with a throwaway pod in tenant-test labelled
  cdi.kubevirt.io=importer that curls the ClusterIP with a range request and
  must get 206 - the exact egress path a real importer faces; tenants use the
  mirror only when that end-to-end check passes, else fall back to the public
  factory, so it can only help, never make CI worse.
- unit test pinning the manifest split, probe/policy label agreement, the
  strict 206 gate, and the --overrides JSON builder.

Also adds (a2) DataVolume import-stage diagnostics to the node-join failure
block in run-kubernetes.sh (importer logs + a ClusterIP re-probe via
talos_image_cache_diagnose) so the import-stage failure mode is legible.

Assisted-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-07-09 18:21:07 +05:00

279 lines
12 KiB
YAML

# In-sandbox caching mirror for the Talos Image Factory worker OS disk image.
#
# Why this exists: tenant Kubernetes worker VMs boot from a Talos raw disk image
# that CDI streams over HTTP from the PUBLIC Talos Image Factory
# (https://factory.talos.dev/image/<schematicID>/<version>/openstack-amd64.raw.xz).
# That endpoint does not support byte-range requests (so CDI falls back to a full
# sequential copy-to-scratch) and its egress is intermittently flaky from the CI
# runner — a mid-transfer HTTP/2 stream reset or a stalled download makes the
# worker DataVolume import never finish, the worker VM never boots, no tenant
# node joins within the chart's 12-minute node-join deadline, and the
# `kubernetes-*` e2e tests fail (and, even when they pass, take 18-22 minutes
# each dominated by these downloads). See cozystack/cozystack#3231.
#
# This Deployment fetches the image ONCE at e2e-install time with an aggressive
# curl retry loop (decoupling the one flaky external fetch from the many
# per-worker imports), then serves it locally over plain HTTP with range support.
# Tenant `Kubernetes` CRs point `spec.talos.imageFactoryURL` at this Service
# (see hack/e2e-chainsaw/_lib/run-kubernetes.sh) so every worker imports from the
# warm local copy instead of the public internet. Tenant namespaces run under a
# default-deny Cilium egress installed by the tenant chart, whose allow rules cover
# same-namespace endpoints, the tenant tree and the `world` entity (which is why the
# public factory works) but NOT an arbitrary kube-system Service, so a worker's CDI
# importer would resolve this Service yet have its TCP connect to the ClusterIP
# silently dropped. The CiliumClusterwideNetworkPolicy at the end of this file punches a
# single tightly-scoped hole for that path. The e2e harness only points tenants
# here after a tenant-scoped probe confirms the importer path actually reaches the
# ClusterIP with a 206; otherwise it falls back to the public factory, so the
# mirror can only help, never make CI worse.
#
# The __SCHEMATIC_ID__ / __TALOS_VERSION__ placeholders are substituted at apply
# time by hack/e2e-install-cozystack.bats from packages/apps/kubernetes/values.yaml.
#
# CI band-aid, not a product artifact. The durable fix is the overridable
# `talos.imageFactoryURL` chart value this manifest exercises; a self-hosted
# Talos Image Factory or an operator's own mirror is the production path.
apiVersion: v1
kind: Service
metadata:
name: talos-image-cache
namespace: kube-system
labels:
app.kubernetes.io/name: talos-image-cache
spec:
selector:
app.kubernetes.io/name: talos-image-cache
ports:
- name: http
port: 80
targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: talos-image-cache
namespace: kube-system
labels:
app.kubernetes.io/name: talos-image-cache
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: talos-image-cache
strategy:
type: Recreate
template:
metadata:
labels:
app.kubernetes.io/name: talos-image-cache
spec:
# The seed fetch and static serving use ordinary pod networking — the same
# egress path the CDI importer pods already use to reach factory.talos.dev.
initContainers:
# Fetch the compressed raw disk image ONCE, retrying hard against the
# flaky upstream. The Deployment only becomes Available after this
# succeeds, so "Available" is the e2e harness's signal that the mirror is
# warm and safe to point tenants at.
- name: seed
# Reuse the in-tree, digest-pinned image already shipped by the
# installer and pre-pulled for the cilium-leak-healer watchdog, so this
# pod does not add a fresh external image pull of its own.
image: docker.io/alpine/k8s:1.36.2@sha256:44ef4942e171939b9c665a4a84beb80e2dcdb9a24330d4651cfdfd2e9deecc47
command: [/bin/sh, -ec]
args:
- |
dest="/data/image/__SCHEMATIC_ID__/__TALOS_VERSION__/openstack-amd64.raw.xz"
url="https://factory.talos.dev/image/__SCHEMATIC_ID__/__TALOS_VERSION__/openstack-amd64.raw.xz"
mkdir -p "$(dirname "$dest")"
echo "seeding $dest from $url"
# --retry-all-errors + a long retry budget rides out the mid-transfer
# stream resets that break the CDI importer's single attempt.
# --speed-time/--speed-limit additionally abort a transfer that
# stalls below 1 KiB/s for 60s — a frozen (not reset) download is
# not an "error", so --retry-all-errors alone would let it hang out
# the whole rollout budget; the abort is then retried like any other
# failure.
curl --fail --location --show-error \
--retry 30 --retry-all-errors --retry-delay 15 \
--connect-timeout 30 --speed-time 60 --speed-limit 1024 \
--output "$dest" \
"$url"
ls -la "$dest"
volumeMounts:
- name: data
mountPath: /data
containers:
- name: serve
image: docker.io/alpine/k8s:1.36.2@sha256:44ef4942e171939b9c665a4a84beb80e2dcdb9a24330d4651cfdfd2e9deecc47
# Serve the seeded image over HTTP with byte-range (206) support so CDI
# can use its fast range import instead of the copy-to-scratch fallback
# the range-less public factory forces. The pinned image ships neither a
# `busybox httpd` applet nor a standalone httpd/nginx, and stock
# `python3 -m http.server` ignores Range headers (answers 200, not 206),
# so a tiny range-capable server (serve.py ConfigMap below) runs on the
# image's bundled python3 — no extra package pull that would reintroduce
# a flaky external dependency.
command: [python3, /serve/serve.py, /data, "8080"]
ports:
- name: http
containerPort: 8080
readinessProbe:
# httpGet (not tcpSocket) so readiness reflects the server actually
# answering HTTP, not merely the port being open.
httpGet:
path: /
port: http
initialDelaySeconds: 2
periodSeconds: 5
resources:
requests:
cpu: 20m
memory: 32Mi
limits:
memory: 128Mi
volumeMounts:
- name: data
mountPath: /data
- name: serve-script
mountPath: /serve
volumes:
- name: data
emptyDir: {}
- name: serve-script
configMap:
name: talos-image-cache-serve
---
apiVersion: v1
kind: ConfigMap
metadata:
name: talos-image-cache-serve
namespace: kube-system
labels:
app.kubernetes.io/name: talos-image-cache
data:
# Minimal static file server with HTTP byte-range support. Stock
# `python3 -m http.server` answers Range requests with 200 + the full body;
# this adds the single-range 206 path (Content-Range + Accept-Ranges) CDI
# needs to skip its slow copy-to-scratch import.
serve.py: |
import http.server
import os
import sys
ROOT = sys.argv[1] if len(sys.argv) > 1 else "/data"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8080
class RangeHandler(http.server.SimpleHTTPRequestHandler):
# HTTP/1.1 keep-alive lets CDI pull many ranges over one connection.
protocol_version = "HTTP/1.1"
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=ROOT, **kwargs)
def send_head(self):
path = self.translate_path(self.path)
if os.path.isdir(path):
return super().send_head()
try:
f = open(path, "rb")
except OSError:
self.send_error(404)
return None
size = os.fstat(f.fileno()).st_size
ctype = self.guess_type(path)
rng = self.headers.get("Range", "")
if not rng.startswith("bytes="):
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(size))
self.send_header("Accept-Ranges", "bytes")
self.end_headers()
return f
start_s, _, end_s = rng[6:].split(",")[0].strip().partition("-")
start = int(start_s) if start_s else max(0, size - int(end_s))
end = int(end_s) if start_s and end_s else size - 1
if start >= size or start > end:
self.send_response(416)
self.send_header("Content-Range", "bytes */%d" % size)
self.send_header("Content-Length", "0")
self.end_headers()
f.close()
return None
end = min(end, size - 1)
f.seek(start)
self._remaining = end - start + 1
self.send_response(206)
self.send_header("Content-Type", ctype)
self.send_header("Content-Range", "bytes %d-%d/%d" % (start, end, size))
self.send_header("Content-Length", str(self._remaining))
self.send_header("Accept-Ranges", "bytes")
self.end_headers()
return f
def copyfile(self, source, outputfile):
remaining = getattr(self, "_remaining", None)
if remaining is None:
super().copyfile(source, outputfile)
return
self._remaining = None
while remaining > 0:
chunk = source.read(min(65536, remaining))
if not chunk:
break
outputfile.write(chunk)
remaining -= len(chunk)
with http.server.ThreadingHTTPServer(("0.0.0.0", PORT), RangeHandler) as httpd:
print("serving %s on :%d" % (ROOT, PORT), flush=True)
httpd.serve_forever()
---
# CI-only: allow the tenant CDI importer Pods to reach the mirror.
#
# Tenant namespaces run under a default-deny Cilium egress installed by the tenant
# chart (allow-internal-communication, allow-external-communication and the
# `<tenant>-egress` CiliumClusterwideNetworkPolicy). Their union allows egress to
# same-namespace endpoints, the tenant tree and the `world` entity (so the public
# factory works), but nothing permits egress to an arbitrary kube-system Service —
# so a worker's CDI importer resolves talos-image-cache.kube-system.svc yet its TCP
# connect to the Service ClusterIP is silently dropped (dial ... i/o timeout), the
# disk import never starts, and the tenant node never joins. Cilium unions allow
# rules across policies, so this adds one tightly-scoped allow: importer Pods in
# tenant-test may egress to the mirror Pod in kube-system.
#
# Scoped to tenant-test (which the tenant chart already puts under default-deny
# egress) so it only ever ADDS an allow — it never flips another namespace from
# default-allow to default-deny. The mirror Pod is selected by no ingress policy
# (default-allow ingress), so no ingress rule is needed; replies return on the
# established conntrack entry.
#
# Applied at point-of-use by hack/e2e-chainsaw/_lib/talos-image-cache.sh, not with
# the rest of this manifest in hack/e2e-install-cozystack.bats: that runs before
# Cozystack (and thus before Cilium's CRDs) is installed, so this kind does not yet
# exist when the manifest is first applied.
apiVersion: cilium.io/v2
kind: CiliumClusterwideNetworkPolicy
metadata:
name: e2e-talos-image-cache-importer-egress
labels:
app.kubernetes.io/name: talos-image-cache
spec:
endpointSelector:
matchLabels:
"k8s:cdi.kubevirt.io": importer
"k8s:io.kubernetes.pod.namespace": tenant-test
# No toPorts: the allow is already scoped to a single kube-system pod identity
# (CI-only, torn down with the sandbox), so an L4 port restriction adds no
# meaningful blast-radius reduction — only risk. With kube-proxy-less Cilium the
# importer's connect to the Service ClusterIP is socket-LB translated to the
# backend pod port BEFORE egress policy is evaluated, so a port literal here
# would have to track the container port (8080), not the Service port (80); a
# wrong guess silently drops the traffic and reintroduces the exact public-factory
# fallback this policy exists to prevent.
egress:
- toEndpoints:
- matchLabels:
"k8s:io.kubernetes.pod.namespace": kube-system
"k8s:app.kubernetes.io/name": talos-image-cache