Files
cozystack/hack/e2e-talos-image-cache.yaml
Myasnikov Daniil 186547d835 test(e2e): run the merge-gating lanes on Talos containers
The srv1-srv3 nodes of both lanes that gate a merge -- the same-repo one
in pull-requests.yaml and the fork one in e2e-fork.yaml -- run as Talos
containers instead of QEMU guests, so a tenant worker sits at L2 rather
than L3. Measured in CI on commits where both substrates ran the same
tree: whole-job wall clock down 24-49 minutes, essentially all of it in
Chainsaw, while the two tenant-Kubernetes suites go from 1143-2040s to
466-809s and stop depending on the runner's vgif flag.

The node-join soft-red gate goes with the substrate, which is what it
was for. It existed because nested virtualisation on the shared runners
degraded far enough that a worker registering in minutes elsewhere could
miss any deadline this test could afford; #3513 established that the
discriminator was the host's kvm_amd vgif flag, and removing a nesting
level removes what it acted on. So hack/e2e-node-join-soft-red.sh, the
SOFT-RED-node-join.txt marker and the soft_red job outputs are gone from
all four workflows, and a missed deadline is an ordinary red again. The
::warning annotation survives, because the reason a run went red should
be legible without opening the log, and so does the 124-versus-everything
-else test, because "the workers were slow" and "the wait never ran"
send a reader to different places.

Done by swapping the substrate inside the existing e2e jobs rather than
by promoting a separate container job. Those jobs carry more than a
substrate -- the GitHub App token, the report-overrun guard, the images
list, the SSH breakpoint -- and promoting a job that never had them
would have dropped four features without saying so.

Three properties of container mode drive the design, and each fails
quietly rather than loudly. machine.kernel.modules is a silent no-op --
kernel_module_spec.go returns early on ModeContainer with no error and
no event -- so the host loads openvswitch and zfs before the nodes start
and the workflow step asserts both rather than trusting them; a missing
module surfaces an hour later looking like a CNI or a storage
regression. Docker gives a privileged container its own tmpfs /dev
seeded once at creation, so /dev/kvm works but ZFS zvols created later
never appear and linstor-csi fails ControllerPublishVolume; the compose
file binds the host devtmpfs, which is also why talosctl cluster create
docker can never carry LINSTOR. And there is no ARP VIP, so
192.168.123.11 stands in for the QEMU lane's .10.

The QEMU machinery stays in the tree and keeps running: nightly.yaml and
e2e-tag.yaml still call `make prepare-env`, so DRBD, the replicated
StorageClass, its Immediate binding mode, the tenant StorageClass
propagation that only applies to remotely-accessible classes, and the
Cozystack Talos node image with its extensions all keep nightly and
release-candidate coverage. None of them is exercised per pull request
any more. That is the trade.

Nothing on the PR path builds or downloads a nocloud disk now, so
build-talos drops the talos-nocloud step and the talos-image artifact.
build-talos itself stays: finalize consumes its digest fragment.

One property this does not fix, recorded beside the runner class because
it will be read off a red run eventually: a container node reports the
HOST's CPU and memory as capacity, and hack/e2e-container-up.sh counters
that with kubelet systemReserved. That corrects the scheduler and not
what a pod reads from /proc, so a workload sizing itself from
/proc/cpuinfo or /proc/meminfo is configured for the whole runner.

Also carried here because the same files carry them: the ghcr.io
pull-through mirror removal (see #4007 -- whichever lands first makes the
other a no-op), the authoritative HelmRelease readiness gate, the
backup-credential preflight, the prepull guard against an image-less
container fragment, and folding the two standalone OIDC suites into the
latest-version tenant cluster.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-09-05 13:19:17 +05:00

299 lines
13 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
import time
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._remaining = size
self._transfer_range = "full"
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._transfer_range = "%d-%d/%d" % (start, end, size)
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
requested = remaining
sent = 0
started = time.monotonic()
try:
while remaining > 0:
chunk = source.read(min(65536, remaining))
if not chunk:
break
outputfile.write(chunk)
sent += len(chunk)
remaining -= len(chunk)
finally:
status = "complete" if sent == requested else "interrupted"
self.log_message(
"transfer-finished status=%s path=%s range=%s requested=%d sent=%d duration=%.3fs",
status,
self.path,
getattr(self, "_transfer_range", "unknown"),
requested,
sent,
time.monotonic() - started,
)
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