mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-09 22:13:18 +09:00
Every tenant worker VM imports the same Talos raw image from the Image Factory over HTTP, once per worker: a 20-worker cluster fetches the identical ~4.15 GiB artifact 20 times, and each fetch is a live dependency on the Factory at exactly the moment a node joins. packages/system/kubernetes-worker-image is a new opt-in catalog that holds one golden DataVolume per (schematicID, version) in cozy-public, named talos-worker-<schematicID>-<version>. A worker pool opts in with osImage.builtin on its own KubernetesNodes resource, and its disk-system DataVolume then carries source.pvc pointing at that golden, so CDI performs a storage-layer CSI clone instead of an HTTP import -- one import per flavor, shared by every worker of every tenant. Confirmed on a LINSTOR-backed cluster: the worker disk is a CSI clone plus a resize, with no host-assisted upload pod. Both halves are off unless asked for. The catalog is registered through the platform's optional-package helper and disabled by default, so a cluster that does not enable it creates no golden and keeps importing over HTTP. The disk source is derived entirely from osImage, which is absent by default, so a pool that does not set it renders byte-identical to the pre-feature chart and adopting the feature never rolls existing workers. Editing osImage on an existing pool re-images that pool, as changing any other image reference does. What the render refuses, rather than degrading into a silent fallback: - A pool whose golden has no catalog entry, or whose golden terminally failed to import. A golden that is merely mid-import is not an error; CDI holds the worker disk until the source populates. - A pool whose StorageClass differs from its golden's. CDI cannot CSI-clone across classes and falls back to a host-assisted copy over the pod network, which is the transfer this feature exists to remove. An empty class on either side means the cluster default and is resolved before the comparison, and a pool that has already cloned reads its class from its own bound PVC rather than from today's default -- the golden's side is frozen the same way, and an admin moving storageclass.kubernetes.io/is-default-class must not fail the release of a pool whose disks are all on the right class. That resolved class is then written onto the worker disk, so the disk binds where the guard checked instead of on whatever the default has become by then -- reading one class and binding another is how a pool at an empty storageClass passed the comparison and still took the host-assisted copy. The HTTP path leaves an empty class to the cluster as before, which is what keeps its render, and the KubevirtMachineTemplate hash named after it, byte-identical. - A diskSize below the golden's storage size, which a clone target cannot be. - A schematicID that is not a plain lowercase alphanumeric identifier, or a version that is not a vMAJOR.MINOR.PATCH release. Both are interpolated into a DataVolume name and an image URL, and values.schema.json types them as bare strings, so a malformed one otherwise reaches the render. The catalog applies the same patterns, since both sides build the same name from the same fields. - An images[] entry edited after its golden exists. A DataVolume spec cannot be patched in place, so the message names the entry and the supported move -- a new (schematicID, version) -- instead of leaving the API server to reject the patch on the next upgrade. Goldens carry helm.sh/resource-policy: keep, so dropping an entry stops managing a golden rather than deleting it: a missing golden aborts the whole Kubernetes HelmRelease of every tenant pinned to it, not just the disk. Each cloned worker disk annotates the golden it came from and the pool that owns it, so "does anything still clone this?" is answerable before a manual delete instead of after. A builtin pool needs a StorageClass whose volumes are reachable from any node, in practice a replicated/DRBD one. The golden in cozy-public has no consumer and binds as soon as it imports, wherever CDI's importer landed, while a worker disk binds where its VM is scheduled, and nothing here arranges for those to be the same node. The platform ships cloneStrategyOverride: csi-clone, which rules out the host-assisted strategy that would cross nodes. What a node-pinned class does in that situation is not measured, so values.yaml states the requirement and not a symptom. ComputePlane pools forward only the per-pool shape fields, and osImage is excluded there by name: the worker OS image, its Talos installer and its Image Factory are operator decisions about the platform's own images. Assisted-by: LLM Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com> Signed-off-by: Aleksei Sviridkin <f@lex.la>
176 lines
9.1 KiB
Bash
176 lines
9.1 KiB
Bash
#!/usr/bin/env bats
|
|
# -----------------------------------------------------------------------------
|
|
# Ties the kubernetes-nodes chart's default Talos image to the kubernetes-worker-image
|
|
# catalog's default golden. The two live in independently edited files:
|
|
#
|
|
# packages/apps/kubernetes-nodes/values.yaml talos.{schematicID,version}
|
|
# packages/system/kubernetes-worker-image/values.yaml images[], storageClass
|
|
#
|
|
# A worker pool that opts in with `osImage.builtin: {}` resolves the golden it
|
|
# clones from the POOL chart's talos defaults, then requires the catalog to hold a
|
|
# matching (schematicID, version) — packages/apps/kubernetes-nodes/templates/
|
|
# nodegroup.yaml fails the whole render when it does not. So a one-sided Talos bump
|
|
# silently breaks every fresh install using osImage.builtin, and nothing catches it
|
|
# until a ~95-minute e2e run does.
|
|
#
|
|
# The StorageClass is the same trap from the other direction: CDI cannot
|
|
# CSI-clone across StorageClasses (it would silently fall back to a host-assisted
|
|
# copy over the pod network), so the render also rejects a group whose
|
|
# storageClass differs from its golden's. Bumping one file's default alone brings
|
|
# that guard down on the default path.
|
|
#
|
|
# Both checks are cheap and exact, which is the point — they turn a slow, remote
|
|
# e2e failure into a local one-second unit test.
|
|
#
|
|
# Compatible with both `bats` directly and the in-repo cozytest.sh runner, which
|
|
# runs each @test in a fresh subshell with `set -u` and does not honor bats
|
|
# setup()/teardown().
|
|
|
|
REPO_ROOT="$(cd "$(dirname "${BATS_TEST_FILENAME:-$0}")/.." && pwd)"
|
|
APP_VALUES="$REPO_ROOT/packages/apps/kubernetes-nodes/values.yaml"
|
|
CATALOG_VALUES="$REPO_ROOT/packages/system/kubernetes-worker-image/values.yaml"
|
|
|
|
@test "worker image catalog holds a golden for the pool chart's default Talos (schematicID, version)" {
|
|
sid=$(yq '.talos.schematicID' "$APP_VALUES")
|
|
ver=$(yq '.talos.version' "$APP_VALUES")
|
|
[ -n "$sid" ] && [ "$sid" != "null" ]
|
|
[ -n "$ver" ] && [ "$ver" != "null" ]
|
|
|
|
# Any entry may match — the catalog is a list of flavors, not an ordered pair.
|
|
if ! yq '.images[] | .schematicID + " " + .version' "$CATALOG_VALUES" \
|
|
| grep -qxF "$sid $ver"; then
|
|
echo "packages/apps/kubernetes-nodes/values.yaml defaults to Talos:" >&2
|
|
echo " schematicID: $sid" >&2
|
|
echo " version: $ver" >&2
|
|
echo "but the kubernetes-worker-image catalog has no matching images[] entry:" >&2
|
|
yq '.images[] | " - " + .schematicID + " " + .version' "$CATALOG_VALUES" >&2
|
|
echo "Every worker pool using 'osImage.builtin: {}' would fail to render." >&2
|
|
echo "Add the pair to the catalog, or bump both files together." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
@test "worker image catalog golden shares the pool chart's default StorageClass" {
|
|
sid=$(yq '.talos.schematicID' "$APP_VALUES")
|
|
ver=$(yq '.talos.version' "$APP_VALUES")
|
|
app_sc=$(yq '.storageClass' "$APP_VALUES")
|
|
|
|
# The golden's effective class is its own override when set, else the catalog
|
|
# default — mirroring how the pool chart resolves its class.
|
|
entry_sc=$(SID="$sid" VER="$ver" yq \
|
|
'.images[] | select(.schematicID == strenv(SID) and .version == strenv(VER)) | .storageClass' \
|
|
"$CATALOG_VALUES")
|
|
if [ -z "$entry_sc" ] || [ "$entry_sc" = "null" ]; then
|
|
entry_sc=$(yq '.storageClass' "$CATALOG_VALUES")
|
|
fi
|
|
|
|
if [ "$app_sc" != "$entry_sc" ]; then
|
|
echo "StorageClass mismatch on the default osImage.builtin path:" >&2
|
|
echo " packages/apps/kubernetes-nodes/values.yaml storageClass: $app_sc" >&2
|
|
echo " kubernetes-worker-image golden effective storageClass: $entry_sc" >&2
|
|
echo "CDI cannot CSI-clone across StorageClasses, so the tenant render" >&2
|
|
echo "rejects this outright. Keep the two defaults in step." >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Second family of default-drift guards on the same chart: the Talos <-> Kubernetes
|
|
# support matrix. The render guard keyed only on `talos.version`, so a pool could
|
|
# reach a different Talos release through `osImage.builtin.version` /
|
|
# `osImage.factory.version` and boot a kubelet outside its support window with no
|
|
# render failure. The table now lives once in templates/_helpers.tpl and both
|
|
# paths consult it.
|
|
#
|
|
# The symmetry is structural, not observable from a render: the matrix ships one
|
|
# row (`v1.13`) whose Kubernetes list is a superset of `values.schema.json`'s
|
|
# `version` enum, so no schema-valid pairing can make either call site fail today.
|
|
# A helm-unittest case would have to put an out-of-enum `version` in, and
|
|
# helm-unittest validates the schema, so it cannot. These two guards cover what a
|
|
# render cannot: that both call sites exist, and that the table still means
|
|
# something for the defaults the chart ships.
|
|
|
|
NODES_TEMPLATE="$REPO_ROOT/packages/apps/kubernetes-nodes/templates/nodegroup.yaml"
|
|
NODES_SCHEMA="$REPO_ROOT/packages/apps/kubernetes-nodes/values.schema.json"
|
|
|
|
@test "the support matrix is consulted for the image-resolved Talos version, not only talos.version" {
|
|
calls=$(grep -c 'include "kubernetes-nodes.assertTalosSupportsKubernetes"' "$NODES_TEMPLATE" || true)
|
|
if [ "$calls" -lt 2 ]; then
|
|
echo "nodegroup.yaml calls the Talos support-matrix guard $calls time(s); it needs one for talos.version and one for the version osImage.builtin/osImage.factory resolves to" >&2
|
|
echo "without the second call a pool keeps a supported talos.version and still boots an unsupported Talos minor through its image override, with no render failure" >&2
|
|
return 1
|
|
fi
|
|
# And the second call must be fed the RESOLVED version. Passing talos.version
|
|
# twice would satisfy the count above while checking nothing new.
|
|
if ! grep -q '"talosVersion" \$reqVersion' "$NODES_TEMPLATE"; then
|
|
echo "the second support-matrix call does not pass \$reqVersion, so it re-checks the value the first call already checked" >&2
|
|
return 1
|
|
fi
|
|
}
|
|
|
|
@test "the support matrix covers every Kubernetes version the schema offers, for the default Talos" {
|
|
ver=$(yq '.talos.version' "$APP_VALUES")
|
|
minor=$(printf '%s\n' "$ver" | grep -oE '^v[0-9]+\.[0-9]+')
|
|
[ -n "$minor" ]
|
|
|
|
helpers="$REPO_ROOT/packages/apps/kubernetes-nodes/templates/_helpers.tpl"
|
|
# The row is a Helm dict literal: `"v1.13" (list "v1.31" "v1.32" ...)`.
|
|
row=$(grep -oE "\"${minor}\" \(list [^)]*\)" "$helpers" || true)
|
|
if [ -z "$row" ]; then
|
|
echo "the Talos support matrix in $helpers has no row for ${minor}, the default talos.version ($ver)" >&2
|
|
echo "an unlisted Talos minor passes the guard unchecked, so the guard means nothing on the shipped defaults" >&2
|
|
return 1
|
|
fi
|
|
|
|
# Every version the schema lets an operator pick must be in that row, or the
|
|
# guard rejects a combination the API advertises as valid. Capture the enum
|
|
# first and refuse an empty read: iterating straight over the command
|
|
# substitution cannot tell "no versions" from "that query found nothing", so a
|
|
# renamed or relocated property would run the loop zero times and report
|
|
# success -- the drift this test exists to catch would be the change that
|
|
# disarms it.
|
|
enum=$(yq -r '.properties.version.enum[]' "$NODES_SCHEMA" || true)
|
|
if [ -z "$enum" ]; then
|
|
echo "could not read .properties.version.enum from $NODES_SCHEMA" >&2
|
|
echo "the enum was renamed, moved, or the file relocated; this guard cannot check the matrix without it" >&2
|
|
return 1
|
|
fi
|
|
for k in $enum; do
|
|
case "$row" in
|
|
*"\"$k\""*) ;;
|
|
*)
|
|
echo "values.schema.json offers version $k, but the Talos ${minor} matrix row does not list it:" >&2
|
|
echo " $row" >&2
|
|
echo "either the row is stale or the enum gained a version Talos ${minor} does not support" >&2
|
|
return 1 ;;
|
|
esac
|
|
done
|
|
}
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Third guard, and the premise the first one rests on. The catalog is keyed off
|
|
# the kubernetes-nodes chart's `talos` defaults, but the parent kubernetes chart
|
|
# carries its own copy of the same block since the Phase 2 split, and its own
|
|
# values.yaml says to keep the two in sync. Nothing enforced that: the pool
|
|
# chart's `make update` copies files/versions.yaml and images/kubectl.tag from
|
|
# the parent and not the talos block, so a one-sided bump leaves the parent's
|
|
# support-matrix guard reasoning about one Talos release while every worker boots
|
|
# another — and the catalog check above would go on passing, because it only ever
|
|
# reads one of the two files.
|
|
|
|
PARENT_VALUES="$REPO_ROOT/packages/apps/kubernetes/values.yaml"
|
|
|
|
@test "the parent kubernetes chart and kubernetes-nodes agree on their Talos defaults" {
|
|
for key in version schematicID imageFactoryURL installerRepository registryMirrors; do
|
|
parent=$(yq ".talos.${key}" "$PARENT_VALUES")
|
|
pool=$(yq ".talos.${key}" "$APP_VALUES")
|
|
if [ "$parent" != "$pool" ]; then
|
|
echo "talos.${key} differs between the two charts:" >&2
|
|
echo " packages/apps/kubernetes/values.yaml: $parent" >&2
|
|
echo " packages/apps/kubernetes-nodes/values.yaml: $pool" >&2
|
|
echo "both are declared 'keep in sync' and a worker pool follows the second while the parent's support-matrix guard reads the first" >&2
|
|
return 1
|
|
fi
|
|
done
|
|
}
|