cozystack/hack/e2e-install-cozystack.bats
Myasnikov Daniil 0fc61eeae6
e2e: pre-pull timing-sensitive platform images before install
Some workloads (OVN raft, LINSTOR controller) fail when replicas start
at different times due to image-pull stagger across nodes. Add a
DaemonSet-based pre-pull step that runs before helm install, ensuring
all nodes have the images cached so every replica starts within
milliseconds of each other.

The script accepts image refs on stdin and creates one container per
image (parallel pulls, total time = max of any single image rather than
sum). The bats test sources images directly from the rendered charts
via yq, walking only PodSpec-shaped objects so version bumps stay in
sync automatically without a separate hardcoded list.

Signed-off-by: Myasnikov Daniil <myasnikovdaniil2001@gmail.com>
2026-04-29 16:18:19 +05:00

283 lines
14 KiB
Bash
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bats
@test "Required installer chart exists" {
if [ ! -f packages/core/installer/Chart.yaml ]; then
echo "Missing: packages/core/installer/Chart.yaml" >&2
exit 1
fi
}
@test "Pre-pull platform images" {
# Cluster-member workloads (OVN raft, LINSTOR) fail if replicas start at
# different times due to image-pull stagger across nodes. Pre-pull these
# images to every node so all replicas start with images already cached.
#
# Source images directly from the rendered charts so version bumps stay in
# sync automatically. yq walks every PodSpec-shaped object and emits the
# images of each container — this scopes the result to images the kubelet
# actually pulls (skips configmap fields and CRD examples that happen to
# contain an `image:` key). Add a chart here when a new peer-sensitive
# workload is found.
{
helm template packages/system/kubeovn
helm template packages/system/linstor
} | yq -N '
(..|select(has("containers"))|.containers[]|.image),
(..|select(has("initContainers"))|.initContainers[]|.image)
' | hack/e2e-prepull-images.sh
}
@test "Install Cozystack" {
# Pre-create the cozy-system namespace with the labels the operator pod needs.
# The chart no longer ships a Namespace resource (helm v3's --create-namespace
# collides with chart-defined Namespace). On Talos clusters that enforce PSA
# baseline-by-default, the cozystack-operator pod (hostNetwork=true) is
# rejected unless the namespace is labelled enforce=privileged BEFORE the
# pod is scheduled. A pre-install hook can't bootstrap that label because
# the hook itself runs in the same namespace and faces the same PSA gate.
# So apply the namespace from the caller (this bats / production install docs)
# — same pattern as kube-prometheus-stack, argo-cd, cert-manager.
kubectl apply -f - <<'EOF'
apiVersion: v1
kind: Namespace
metadata:
name: cozy-system
labels:
cozystack.io/system: "true"
cozystack.io/deletion-protected: "true"
pod-security.kubernetes.io/enforce: privileged
EOF
# Install cozy-installer chart (operator installs CRDs on startup via --install-crds).
helm upgrade installer packages/core/installer \
--install \
--namespace cozy-system \
--set cozystackOperator.helmReleaseInterval=30s \
--wait \
--timeout 2m
# Verify the operator deployment is available
kubectl wait deployment/cozystack-operator -n cozy-system --timeout=1m --for=condition=Available
# Wait for operator to install CRDs (happens at startup before reconcile loop).
# kubectl wait fails immediately if the CRD does not exist yet, so poll until it appears first.
timeout 120 sh -ec 'until kubectl wait crd/packages.cozystack.io --for=condition=Established --timeout=10s 2>/dev/null; do sleep 2; done'
timeout 120 sh -ec 'until kubectl wait crd/packagesources.cozystack.io --for=condition=Established --timeout=10s 2>/dev/null; do sleep 2; done'
# Wait for operator to create the platform PackageSource
timeout 120 sh -ec 'until kubectl get packagesource cozystack.cozystack-platform >/dev/null 2>&1; do sleep 2; done'
# Create platform Package with isp-full variant
kubectl apply -f - <<EOF
apiVersion: cozystack.io/v1alpha1
kind: Package
metadata:
name: cozystack.cozystack-platform
spec:
variant: isp-full
components:
platform:
values:
networking:
podCIDR: "10.244.0.0/16"
podGateway: "10.244.0.1"
serviceCIDR: "10.96.0.0/16"
joinCIDR: "100.64.0.0/16"
publishing:
host: "example.org"
apiServerEndpoint: "https://192.168.123.10:6443"
bundles:
enabledPackages:
- cozystack.external-dns-application
EOF
# Launch storage + LB configuration in the background. It waits for its
# own prerequisites (linstor-controller deploy, MetalLB CRDs) and finishes
# while the parallel HR wait below is still running, so the cost overlaps
# with the platform reconcile instead of compounding it.
hack/e2e-post-install-prep.sh > /tmp/post-install-prep.log 2>&1 &
POST_PREP_PID=$!
# Wait until HelmReleases appear & reconcile them
timeout 180 sh -ec 'until [ $(kubectl get hr -A --no-headers 2>/dev/null | wc -l) -gt 10 ]; do sleep 1; done'
# TODO(e2e-replace-fixed-timeouts): genuine sleep. The threshold of 10 is a
# heuristic for "enough HRs visible to start waiting"; the snapshot below
# uses whatever HRs have appeared by then. There is no objective k8s API
# signal for "all platform HRs have been emitted" without hard-coding the
# expected list, so the 5s pad lets a few late-arrivals join the snapshot.
sleep 5
kubectl get hr -A | awk 'NR>1 {print "kubectl wait --timeout=15m --for=condition=ready -n "$1" hr/"$2" &"} END {print "wait"}' | sh -ex
echo "Waiting for post-install-prep to complete"
if ! wait $POST_PREP_PID; then
cat /tmp/post-install-prep.log >&2
echo "post-install-prep failed" >&2
exit 1
fi
cat /tmp/post-install-prep.log
# Fail the test if any HelmRelease is not Ready
if kubectl get hr -A | grep -v " True " | grep -v NAME; then
kubectl get hr -A
echo "Some HelmReleases failed to reconcile" >&2
fi
}
@test "Wait for ClusterAPI provider deployments" {
# Wait for ClusterAPI provider deployments
timeout 120 sh -ec 'until kubectl get deploy -n cozy-cluster-api capi-controller-manager capi-kamaji-controller-manager capi-kubeadm-bootstrap-controller-manager capi-operator-cluster-api-operator capk-controller-manager >/dev/null 2>&1; do sleep 1; done'
kubectl wait deployment/capi-controller-manager deployment/capi-kamaji-controller-manager deployment/capi-kubeadm-bootstrap-controller-manager deployment/capi-operator-cluster-api-operator deployment/capk-controller-manager -n cozy-cluster-api --timeout=2m --for=condition=available
}
@test "Check Cozystack API service" {
timeout 60 sh -ec 'until kubectl get apiservices/v1alpha1.apps.cozystack.io apiservices/v1alpha1.core.cozystack.io >/dev/null 2>&1; do sleep 2; done'
kubectl wait --for=condition=Available apiservices/v1alpha1.apps.cozystack.io apiservices/v1alpha1.core.cozystack.io --timeout=2m
}
@test "Configure Tenant and wait for applications" {
# Patch root tenant and wait for its releases
kubectl patch tenants/root -n tenant-root --type merge -p '{"spec":{"host":"example.org","ingress":true,"monitoring":true,"etcd":true,"isolated":true, "seaweedfs": true}}'
timeout 60 sh -ec 'until kubectl get hr -n tenant-root etcd ingress monitoring seaweedfs tenant-root >/dev/null 2>&1; do sleep 1; done'
kubectl wait hr/etcd hr/ingress hr/tenant-root hr/seaweedfs -n tenant-root --timeout=4m --for=condition=ready
# TODO: Workaround ingress unvailability issue
if ! kubectl wait hr/monitoring -n tenant-root --timeout=2m --for=condition=ready; then
flux reconcile hr monitoring -n tenant-root --force
kubectl wait hr/monitoring -n tenant-root --timeout=2m --for=condition=ready
fi
if ! kubectl wait hr/seaweedfs-system -n tenant-root --timeout=2m --for=condition=ready; then
flux reconcile hr seaweedfs-system -n tenant-root --force
kubectl wait hr/seaweedfs-system -n tenant-root --timeout=2m --for=condition=ready
fi
# Expose Cozystack services through ingress
kubectl patch package cozystack.cozystack-platform --type merge -p '{"spec":{"components":{"platform":{"values":{"publishing":{"exposedServices":["api","dashboard","cdi-uploadproxy","vm-exportproxy","keycloak"]}}}}}}'
# NGINX ingress controller
timeout 60 sh -ec 'until kubectl get deploy root-ingress-controller -n tenant-root >/dev/null 2>&1; do sleep 1; done'
kubectl wait deploy/root-ingress-controller -n tenant-root --timeout=5m --for=condition=available
# etcd statefulset
timeout 60 sh -ec 'until kubectl get sts/etcd -n tenant-root >/dev/null 2>&1; do sleep 2; done'
kubectl wait sts/etcd -n tenant-root --for=jsonpath='{.status.readyReplicas}'=3 --timeout=5m
# VictoriaMetrics components
timeout 60 sh -ec 'until kubectl get vmalert/vmalert-shortterm -n tenant-root >/dev/null 2>&1; do sleep 2; done'
timeout 60 sh -ec 'until kubectl get vmalertmanager/alertmanager -n tenant-root >/dev/null 2>&1; do sleep 2; done'
kubectl wait vmalert/vmalert-shortterm vmalertmanager/alertmanager -n tenant-root --for=jsonpath='{.status.updateStatus}'=operational --timeout=15m
timeout 60 sh -ec 'until kubectl get vlclusters/generic -n tenant-root >/dev/null 2>&1; do sleep 2; done'
kubectl wait vlclusters/generic -n tenant-root --for=jsonpath='{.status.updateStatus}'=operational --timeout=5m
timeout 60 sh -ec 'until kubectl get vmcluster/shortterm vmcluster/longterm -n tenant-root >/dev/null 2>&1; do sleep 2; done'
kubectl wait vmcluster/shortterm vmcluster/longterm -n tenant-root --for=jsonpath='{.status.updateStatus}'=operational --timeout=5m
# Grafana
timeout 60 sh -ec 'until kubectl get clusters.postgresql.cnpg.io/grafana-db -n tenant-root >/dev/null 2>&1; do sleep 2; done'
kubectl wait clusters.postgresql.cnpg.io/grafana-db -n tenant-root --for=condition=ready --timeout=5m
timeout 60 sh -ec 'until kubectl get deploy/grafana-deployment -n tenant-root >/dev/null 2>&1; do sleep 2; done'
kubectl wait deploy/grafana-deployment -n tenant-root --for=condition=available --timeout=5m
# Verify Grafana via ingress
ingress_ip=$(kubectl get svc root-ingress-controller -n tenant-root -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
if ! curl -sS -k "https://${ingress_ip}" -H 'Host: grafana.example.org' --max-time 30 | grep -q Found; then
echo "Failed to access Grafana via ingress at ${ingress_ip}" >&2
exit 1
fi
}
@test "Keycloak OIDC stack is healthy" {
kubectl patch package cozystack.cozystack-platform --type merge -p '{"spec":{"components":{"platform":{"values":{"authentication":{"oidc":{"enabled":true}}}}}}}'
timeout 120 sh -ec 'until kubectl get hr -n cozy-keycloak keycloak keycloak-configure keycloak-operator >/dev/null 2>&1; do sleep 1; done'
kubectl wait hr/keycloak hr/keycloak-configure hr/keycloak-operator -n cozy-keycloak --timeout=10m --for=condition=ready
}
@test "Aggregated API rejects Tenant name with dashes" {
# Regression guard: the tenant Helm chart's tenant.name helper splits the
# Release.Name on "-" and fails unless the result is exactly
# ["tenant", "<name>"]. The aggregated API must catch tenant names
# containing dashes up-front with a tenant-specific error, instead of
# silently accepting the Application and letting Flux fail later.
# Defensive cleanup: if a prior regression left foo-bar in the cluster,
# remove it before exercising the validation so we are not observing
# stale state. Safe even in the happy path because of --ignore-not-found.
kubectl delete tenants.apps.cozystack.io foo-bar -n tenant-root --ignore-not-found
# Preflight: tenant-root is created by earlier tests in this suite. Fail
# loudly if it is missing so this test does not silently trigger an
# unrelated "namespace not found" error and misreport as a pass.
kubectl get namespace tenant-root
# --validate=ignore forces kubectl to skip client-side OpenAPI validation
# and send the payload straight to the aggregated API. This guarantees the
# server-side name check runs and the error we grep for is the tenant
# contract error, not a kubectl schema rejection. (--validate=false is the
# deprecated alias.)
local output rc
# Run the apply in its own subshell so we can capture BOTH stdout+stderr
# AND the exit code explicitly, without `|| true` swallowing a real failure
# mode (e.g. network error, auth failure) that should also fail the test.
output=$(
kubectl apply --validate=ignore -f - 2>&1 <<EOF
apiVersion: apps.cozystack.io/v1alpha1
kind: Tenant
metadata:
name: foo-bar
namespace: tenant-root
spec: {}
EOF
) && rc=0 || rc=$?
echo "kubectl apply exit=$rc, output=$output"
# kubectl MUST have failed: success would mean validation regressed.
[ "$rc" -ne 0 ]
# Assert the tenant-specific message is present (distinguishes from
# generic DNS-1035 errors and from network/auth failures).
echo "$output" | grep -q "tenant names must"
# And assert kubectl did NOT report creation — if validation regressed
# into a "warn" variant, the server could still accept the object.
! echo "$output" | grep -qi "created"
# Post-condition cleanup: even though we expect validation to reject the
# create, removing foo-bar unconditionally keeps the cluster clean for
# subsequent tests in case validation regresses and the object is created.
kubectl delete tenants.apps.cozystack.io foo-bar -n tenant-root --ignore-not-found
}
@test "Create tenant with isolated mode enabled" {
kubectl -n tenant-root get tenants.apps.cozystack.io test ||
kubectl apply -f - <<EOF
apiVersion: apps.cozystack.io/v1alpha1
kind: Tenant
metadata:
name: test
namespace: tenant-root
spec:
etcd: false
host: ""
ingress: false
isolated: true
monitoring: false
resourceQuotas:
cpu: "60"
memory: "128Gi"
storage: "100Gi"
seaweedfs: false
EOF
kubectl wait hr/tenant-test -n tenant-root --timeout=1m --for=condition=ready
timeout 60 sh -ec 'until kubectl get namespace tenant-test >/dev/null 2>&1; do sleep 2; done'
kubectl wait namespace tenant-test --timeout=20s --for=jsonpath='{.status.phase}'=Active
# Wait for ResourceQuota to appear and assert values
timeout 60 sh -ec 'until [ "$(kubectl get quota -n tenant-test --no-headers 2>/dev/null | wc -l)" -ge 1 ]; do sleep 1; done'
kubectl get quota -n tenant-test \
-o jsonpath='{range .items[*]}{.spec.hard.requests\.memory}{" "}{.spec.hard.requests\.storage}{"\n"}{end}' \
| grep -qx '137438953472 100Gi'
# Assert LimitRange defaults for containers
kubectl get limitrange -n tenant-test \
-o jsonpath='{range .items[*].spec.limits[*]}{.default.cpu}{" "}{.default.memory}{" "}{.defaultRequest.cpu}{" "}{.defaultRequest.memory}{"\n"}{end}' \
| grep -qx '250m 128Mi 25m 128Mi'
}