diff --git a/.github/workflows/lint.yaml b/.github/workflows/checks.yaml
similarity index 50%
rename from .github/workflows/lint.yaml
rename to .github/workflows/checks.yaml
index 144ef918..bd1718dc 100644
--- a/.github/workflows/lint.yaml
+++ b/.github/workflows/checks.yaml
@@ -1,4 +1,4 @@
-name: lint
+name: checks
on:
push:
@@ -26,3 +26,23 @@ jobs:
with:
key: script-lint-${{ github.ref_name }}-${{ github.run_id }}
path: ~/.cache/ystack
+
+ itest:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/cache/restore@v4
+ with:
+ key: itest-${{ github.ref_name }}-
+ restore-keys: |
+ itest-main-
+ path: ~/.cache/ystack
+ - name: Integration tests (yconverge framework)
+ run: yconverge/itest/test.sh
+ env:
+ YSTACK_HOME: ${{ github.workspace }}
+ PATH: ${{ github.workspace }}/bin:/usr/local/bin:/usr/bin:/bin
+ - uses: actions/cache/save@v4
+ with:
+ key: itest-${{ github.ref_name }}-${{ github.run_id }}
+ path: ~/.cache/ystack
diff --git a/.github/workflows/images.yaml b/.github/workflows/images.yaml
index 9719b3cf..8326e04f 100644
--- a/.github/workflows/images.yaml
+++ b/.github/workflows/images.yaml
@@ -6,10 +6,10 @@ on:
- main
jobs:
- lint:
- uses: ./.github/workflows/lint.yaml
+ checks:
+ uses: ./.github/workflows/checks.yaml
docker:
- needs: lint
+ needs: checks
runs-on: ubuntu-latest
permissions:
packages: write
diff --git a/TODO_CONVERGE_DAG.md b/TODO_CONVERGE_DAG.md
new file mode 100644
index 00000000..9bd411e4
--- /dev/null
+++ b/TODO_CONVERGE_DAG.md
@@ -0,0 +1,258 @@
+# Converge DAG: CUE-based cluster convergence
+
+## Problem
+
+Dependencies between backends and modules are implicit in script ordering
+(MANUAL_STEPS_FOR_NEW_SITES, y-site-upgrade, y-cluster-converge-dev).
+Adding or reordering a module means editing a bash script.
+
+## Design
+
+Every kustomize base that can be applied with `kubectl yconverge -k`
+is a **step**. Each step declares its readiness via **checks**.
+Dependencies between steps are expressed as **CUE imports** —
+importing another step's package makes it a precondition.
+
+The dependency graph is the CUE import graph.
+A `cue cmd converge` walks it in topological order.
+
+## ystack provides
+
+Schema in `cue/converge/schema.cue`:
+
+```cue
+package converge
+
+// A convergence step: apply a kustomize base, then verify.
+#Step: {
+ // Path to kustomize directory, relative to repo root.
+ kustomization: string
+ // Namespace override. If unset, kustomization must set it.
+ namespace?: string
+ // Checks that must pass after apply (and that downstream steps
+ // use as preconditions by importing this package).
+ // Empty list means no checks — the step is ready after apply.
+ checks: [...#Check]
+}
+
+// Check is a discriminated union. Each variant maps to a kubectl
+// subcommand that manages its own timeout and output.
+#Check: #Wait | #Rollout | #Exec
+
+// Thin wrapper around kubectl wait.
+// Timeout and output are managed by kubectl.
+#Wait: {
+ kind: "wait"
+ resource: string // e.g. "pod/redpanda-0" or "job/setup-topic"
+ for: string // e.g. "condition=Ready" or "condition=Complete"
+ namespace?: string
+ timeout: *"60s" | string
+ description: *"" | string
+}
+
+// Thin wrapper around kubectl rollout status.
+// Timeout and output are managed by kubectl.
+#Rollout: {
+ kind: "rollout"
+ resource: string // e.g. "deploy/gateway-v4" or "statefulset/redpanda"
+ namespace?: string
+ timeout: *"60s" | string
+ description: *"" | string
+}
+
+// Arbitrary command for checks that don't map to kubectl builtins.
+// The engine retries until timeout.
+#Exec: {
+ kind: "exec"
+ command: string
+ timeout: *"60s" | string
+ description: string
+}
+```
+
+## Validation
+
+`cue vet` validates that every `y-k8s.cue` file conforms to the schema.
+This runs without a cluster — it's a static check on the declarations.
+
+```
+y-cue vet ./...
+```
+
+This catches: missing required fields, wrong check types, invalid
+timeout formats, typos in field names (CUE is closed by default —
+unknown fields are errors).
+
+CI can run `cue vet` to ensure all modules comply before merge.
+
+## Engine
+
+The engine in `cue/converge/converge_tool.cue` translates checks
+to kubectl commands:
+
+```cue
+// #Wait -> kubectl wait --for=$for --timeout=$timeout $resource [-n $namespace]
+// #Rollout -> kubectl rollout status --timeout=$timeout $resource [-n $namespace]
+// #Exec -> retry with $timeout: sh -c $command
+```
+
+`kubectl wait` and `kubectl rollout status` handle their own polling
+and timeout — the engine just propagates the timeout value and
+passes through stdout/stderr.
+
+For `#Exec` checks the engine manages the retry loop.
+
+`kubectl-yconverge` handles the apply modes (create, replace,
+serverside, serverside-force, regular).
+The engine does not need to know about apply strategies.
+
+## Modules provide
+
+Each module has a `y-k8s.cue` that declares its step and checks.
+A module with no checks is valid — it just declares the kustomization.
+
+Example `kafka-v3/y-k8s.cue` (backend with rollout check):
+
+```cue
+package kafka_v3
+
+import "yolean.se/ystack/cue/converge"
+
+step: converge.#Step & {
+ kustomization: "cluster-local/kafka-v3"
+ checks: [
+ {kind: "rollout", resource: "statefulset/redpanda", namespace: "kafka", timeout: "120s"},
+ {kind: "exec", command: "kubectl exec -n kafka redpanda-0 -- rpk cluster info", description: "redpanda cluster healthy"},
+ ]
+}
+```
+
+Example `cluster-local/mysql/y-k8s.cue` (backend, no checks needed):
+
+```cue
+package mysql
+
+import "yolean.se/ystack/cue/converge"
+
+step: converge.#Step & {
+ kustomization: "cluster-local/mysql"
+ checks: []
+}
+```
+
+Example `gateway-v4/y-k8s.cue` (module with dependencies):
+
+```cue
+package gateway_v4
+
+import (
+ "yolean.se/ystack/cue/converge"
+ "yolean.se/checkit/cluster-local/kafka-v3"
+ "yolean.se/checkit/keycloak-v3"
+)
+
+// Importing kafka_v3 and keycloak_v3 makes their checks
+// preconditions for this step. The engine ensures they
+// converge and pass before applying gateway-v4.
+
+step: converge.#Step & {
+ kustomization: "gateway-v4/site-apply-namespaced"
+ checks: [
+ {kind: "rollout", resource: "deploy/gateway-v4", namespace: "dev"},
+ {kind: "wait", resource: "job/setup-topic-gateway-v4-userstate", namespace: "dev", for: "condition=Complete"},
+ ]
+}
+```
+
+## Dependency resolution
+
+The engine collects all `y-k8s.cue` files, inspects their imports,
+and builds a topological sort. A step runs only after all imported
+steps have converged and their checks pass.
+
+Import cycles are a CUE compile error — no runtime cycle detection needed.
+
+## Namespace binding
+
+`y-site-generate` determines which modules a site needs and which
+namespace they target. The CUE engine receives `--context=` and
+site name as inputs. Namespace is either set in the kustomization
+or passed as a CUE value that templates into check commands.
+
+## Convergence is cheap
+
+`kubectl yconverge -k` is idempotent. Re-running a fully converged
+step is a no-op (unchanged resources) followed by passing checks.
+This means:
+
+- No "has this been applied" state tracking
+- Re-running after a failure retries only what's needed
+- Checks serve double duty: post-apply verification AND
+ precondition for downstream steps
+
+## CLI surface
+
+```
+y-cue cmd converge --context=local dev # full site
+y-cue cmd converge --context=local dev gateway-v4 # one module + deps
+y-cue cmd check --context=local dev # checks only, no apply
+y-cue vet ./... # validate all y-k8s.cue
+```
+
+## Proposed: yconverge.cue integration with kubectl-yconverge
+
+Rename `y-k8s.cue` to `yconverge.cue`. The only valid location is
+next to a `kustomization.yaml` file.
+
+When `kubectl yconverge -k
` completes with exit 0, it looks for
+`yconverge.cue` in `/`. If found, it invokes the framework to
+run that step's checks. This means any script that uses `kubectl yconverge`
+automatically gets check verification — no separate orchestration needed.
+
+One level of `resources:` indirection: if the kustomization has exactly
+one `resources:` item pointing to a local directory, and the current
+directory has no `yconverge.cue`, look for `yconverge.cue` in that
+resource directory. This handles the common pattern where
+`cluster-local/kafka-v3/kustomization.yaml` has `resources: [../../kafka-v3/cluster-backend]`
+— the checks can live in `kafka-v3/cluster-backend/yconverge.cue`.
+
+### Trade-off
+
+Pro: Breaks up monolithic provision scripts. Any `kubectl yconverge -k`
+call becomes self-validating. No separate engine invocation needed.
+
+Con: Adds checking overhead to every `kubectl yconverge` call. In
+`y-site-upgrade` which converges many modules in sequence, each apply
+would trigger CUE evaluation + checks. Mitigation: checks should be
+fast (rollout status and kubectl wait already return quickly when
+resources are already ready). Could add `--skip-checks` flag for
+batch operations that do their own validation.
+
+## y-kustomize refresh tracking
+
+When y-kustomize serves content from secrets mounted as volumes,
+it needs a restart when those secrets change. Currently handled by
+an explicit action in `40-kafka-ystack`.
+
+Proposed: y-kustomize stores a hash of its secret contents as an
+annotation on its own deployment:
+
+```
+yolean.se/y-kustomize-secrets-hash: sha256:
+```
+
+After any step applies secrets in the ystack namespace matching
+`y-kustomize.*`, the engine computes the current hash and compares
+to the annotation. Restart only on mismatch. This makes re-converge
+of a fully converged cluster skip the restart entirely.
+
+## Migration path
+
+1. Add schema to ystack `cue/converge/`
+2. Write `y-k8s.cue` for ystack backends (kafka, blobs, builds-registry)
+3. Write `y-k8s.cue` for checkit backends (mysql, keycloak-v3)
+4. Write `y-k8s.cue` for a few site modules (gateway-v4, events-v1)
+5. `cue cmd converge --context=local dev` replaces
+ `y-cluster-provision-first-site dev` for local clusters
+6. Extend to `y-site-upgrade` by adding upgrade-specific checks
+7. Extend to non-local clusters by parameterizing context and namespace
diff --git a/bin/kubectl-yconverge b/bin/kubectl-yconverge
new file mode 100755
index 00000000..8943f6e0
--- /dev/null
+++ b/bin/kubectl-yconverge
@@ -0,0 +1,379 @@
+#!/bin/sh
+[ -z "$DEBUG" ] || set -x
+set -e
+
+_print_help() {
+ cat <<'HELP'
+Idempotent apply with CUE-backed checks.
+
+Usage:
+ kubectl yconverge --context= [flags] -k
+ kubectl yconverge help | --help
+
+Modes (mutually exclusive; default is apply):
+ --diff=true run kubectl diff, no apply, no checks
+ --checks-only run yconverge.cue checks against current state, no apply
+ --print-deps print dependency order from yconverge.cue imports, exit
+
+Apply-mode modifiers:
+ --dry-run=MODE forward to kubectl apply/delete (server|none)
+ (client is rejected: incompatible with --server-side)
+ --skip-checks skip yconverge.cue check invocation after apply
+
+Converge modes (label yolean.se/converge-mode on a resource):
+ (none) standard kubectl apply
+ create kubectl create --save-config (skip if exists)
+ replace kubectl delete + apply (for immutable resources like Jobs)
+ serverside kubectl apply --server-side
+ serverside-force kubectl apply --server-side --force-conflicts
+
+If the -k directory contains a yconverge.cue file (or one is found one
+level of resources: indirection away):
+ - Dependencies from CUE imports are resolved and converged first
+ - Checks run after apply (unless --skip-checks)
+
+Honors KUBECONFIG if set.
+HELP
+}
+
+case "${1:-}" in
+ ""|help|--help|-h)
+ _print_help
+ exit 0
+ ;;
+esac
+
+_die() { echo "Error: $1" >&2; exit 1; }
+
+# --- arg parsing ---
+
+ctx="$1"
+case "$ctx" in
+ "--context="*) shift 1 ;;
+ *) _die "first arg must be --context= (try --help)" ;;
+esac
+CONTEXT="${ctx#--context=}"
+export CONTEXT
+
+MODE="apply"
+DRY_RUN=""
+SKIP_CHECKS=false
+
+_set_mode() {
+ [ "$MODE" = "apply" ] || _die "$1 conflicts with $MODE mode"
+ MODE="$1"
+}
+
+while true; do
+ case "${1:-}" in
+ --diff=true) _set_mode diff; shift ;;
+ --checks-only) _set_mode checks-only; shift ;;
+ --print-deps) _set_mode print-deps; shift ;;
+ --dry-run=*) DRY_RUN="${1#--dry-run=}"; shift ;;
+ --skip-checks) SKIP_CHECKS=true; shift ;;
+ --help|-h) _print_help; exit 0 ;;
+ *) break ;;
+ esac
+done
+
+case "$DRY_RUN" in
+ ""|server|none) ;;
+ client) _die "--dry-run=client is not supported: yconverge uses server-side apply, and kubectl rejects --dry-run=client with --server-side. Use --dry-run=server instead." ;;
+ *) _die "--dry-run must be one of: server, none" ;;
+esac
+
+if [ -n "$DRY_RUN" ] && [ "$MODE" != "apply" ]; then
+ _die "--dry-run is only valid in apply mode (got --$MODE)"
+fi
+if [ "$SKIP_CHECKS" = "true" ] && [ "$MODE" != "apply" ]; then
+ _die "--skip-checks is only valid in apply mode (got --$MODE)"
+fi
+
+# --- extract -k directory from remaining args ---
+
+KUSTOMIZE_DIR=""
+for arg in "$@"; do
+ case "$arg" in
+ -l|--selector) _die "yconverge can not be combined with other selectors" ;;
+ esac
+done
+_prev=""
+for arg in "$@"; do
+ if [ "$_prev" = "-k" ]; then
+ KUSTOMIZE_DIR="${arg%/}"
+ break
+ fi
+ case "$arg" in
+ -k) _prev="-k" ;;
+ -k*) KUSTOMIZE_DIR="${arg#-k}"; KUSTOMIZE_DIR="${KUSTOMIZE_DIR%/}"; break ;;
+ esac
+done
+
+# --- mode args to propagate on recursive calls ---
+
+MODE_ARGS=""
+case "$MODE" in
+ diff) MODE_ARGS="--diff=true" ;;
+ checks-only) MODE_ARGS="--checks-only" ;;
+ print-deps) MODE_ARGS="--print-deps" ;;
+esac
+[ -n "$DRY_RUN" ] && MODE_ARGS="$MODE_ARGS --dry-run=$DRY_RUN"
+[ "$SKIP_CHECKS" = "true" ] && MODE_ARGS="$MODE_ARGS --skip-checks"
+
+# --- diff mode: pass through and exit ---
+
+if [ "$MODE" = "diff" ]; then
+ kubectl $ctx diff "$@"
+ exit $?
+fi
+
+# --- yconverge.cue lookup: finds a yconverge.cue file, with 1-level indirection
+# through a kustomization.yaml that references exactly one local directory. ---
+
+_find_cue_dir() {
+ d="$1"
+ if [ -f "$d/yconverge.cue" ]; then
+ echo "$d"
+ return 0
+ fi
+ [ -f "$d/kustomization.yaml" ] || return 0
+ _resources=$(y-yq '.resources // [] | .[] | select(test("^[^h]") and test("^(http|github)") | not)' "$d/kustomization.yaml")
+ _base_dir=""
+ _dir_count=0
+ _old_ifs="$IFS"; IFS='
+'
+ for _r in $_resources; do
+ if [ -d "$d/$_r" ]; then
+ _dir_count=$((_dir_count + 1))
+ [ "$_dir_count" = "1" ] && _base_dir="$_r"
+ fi
+ done
+ IFS="$_old_ifs"
+ if [ "$_dir_count" = "1" ] && [ -f "$d/$_base_dir/yconverge.cue" ]; then
+ echo "$d/$_base_dir"
+ fi
+ return 0
+}
+
+# --- dependency graph walk via CUE imports ---
+# Emits paths in topological order (deps first, target last). _DEP_VISITED
+# holds already-resolved paths, newline-separated, to avoid re-walks/cycles.
+
+_DEP_VISITED=""
+
+_find_imports() {
+ grep '"yolean.se/ystack/' "$1" 2>/dev/null \
+ | grep -v '"yolean.se/ystack/yconverge/verify"' \
+ | sed 's|.*"yolean.se/ystack/\([^":]*\).*|\1|' \
+ || true # y-script-lint:disable=or-true # no imports is valid
+}
+
+_resolve_deps() {
+ # POSIX sh has no `local`, so recursive calls share named variables.
+ # Reference $1 (positional arg, call-scoped) for the path throughout, and
+ # only read _cue_dir before recursing (its subsequent clobbering is harmless).
+ case "
+$_DEP_VISITED
+" in
+ *"
+${1%/}
+"*) return 0 ;;
+ esac
+ _cue_dir=$(_find_cue_dir "${1%/}")
+ [ -z "$_cue_dir" ] && return 0
+ for _dep in $(_find_imports "$_cue_dir/yconverge.cue"); do
+ _resolve_deps "$_dep"
+ done
+ _DEP_VISITED="$_DEP_VISITED
+${1%/}"
+ echo "${1%/}"
+}
+
+# --- dependency resolution ---
+# On first (top-level) invocation, resolve the full dep graph. For print-deps
+# mode, print and exit. For multi-step graphs, iterate calling self per step
+# and let each run its own apply + checks.
+
+if [ -z "$_YCONVERGE_RESOLVING" ] && [ -n "$KUSTOMIZE_DIR" ]; then
+ deps=$(_resolve_deps "$KUSTOMIZE_DIR")
+ dep_count=$(printf '%s\n' "$deps" | grep -c . 2>/dev/null) || true # y-script-lint:disable=or-true # grep -c . exit 1 = zero matches
+
+ if [ "$MODE" = "print-deps" ]; then
+ printf '%s\n' "$deps"
+ exit 0
+ fi
+
+ if [ "$dep_count" -gt 1 ] 2>/dev/null; then
+ echo "=== Converge plan (context=$CONTEXT, mode=$MODE) ==="
+ echo "Steps ($dep_count):"
+ for d in $deps; do echo " $d"; done
+ echo "==="
+ export _YCONVERGE_RESOLVING=1
+ for d in $deps; do
+ echo ">>> $d"
+ kubectl-yconverge $ctx $MODE_ARGS -k "$d/"
+ done
+ exit 0
+ fi
+fi
+
+# --- single-step path: find yconverge.cue for this target, resolve namespace ---
+
+yconverge_dir=""
+if [ -n "$KUSTOMIZE_DIR" ]; then
+ case "$MODE" in
+ apply)
+ [ "$SKIP_CHECKS" = "false" ] && yconverge_dir=$(_find_cue_dir "$KUSTOMIZE_DIR")
+ ;;
+ checks-only)
+ yconverge_dir=$(_find_cue_dir "$KUSTOMIZE_DIR")
+ [ -z "$yconverge_dir" ] && _die "--checks-only: no yconverge.cue found for $KUSTOMIZE_DIR"
+ ;;
+ esac
+fi
+
+if [ -n "$yconverge_dir" ]; then
+ echo " [yconverge] found $yconverge_dir/yconverge.cue"
+ case "$yconverge_dir" in
+ ./*|/*) ;;
+ *) yconverge_dir="./$yconverge_dir" ;;
+ esac
+fi
+
+# --- resolve namespace guess ---
+# Priority: 1. -n CLI arg
+# 2. outer kustomization namespace: (the rendered namespace kustomize uses)
+# 3. referenced base namespace (fallback when indirection found yconverge.cue
+# and the outer kustomization did not set its own namespace)
+# 4. context default
+NS_GUESS=""
+_prev=""
+for arg in "$@"; do
+ if [ "$_prev" = "-n" ]; then
+ NS_GUESS="$arg"
+ break
+ fi
+ _prev="$arg"
+done
+if [ -z "$NS_GUESS" ] && [ -n "$KUSTOMIZE_DIR" ] && [ -f "$KUSTOMIZE_DIR/kustomization.yaml" ]; then
+ NS_GUESS=$(y-yq '.namespace // ""' "$KUSTOMIZE_DIR/kustomization.yaml")
+fi
+if [ -z "$NS_GUESS" ] && [ -n "$yconverge_dir" ] && [ -n "$KUSTOMIZE_DIR" ] && [ "$yconverge_dir" != "$KUSTOMIZE_DIR" ] && [ "$yconverge_dir" != "./$KUSTOMIZE_DIR" ]; then
+ _ref_kust="$yconverge_dir/kustomization.yaml"
+ [ ! -f "$_ref_kust" ] && _ref_kust="$yconverge_dir/kustomization.yml"
+ [ -f "$_ref_kust" ] && NS_GUESS=$(y-yq '.namespace // ""' "$_ref_kust")
+fi
+if [ -z "$NS_GUESS" ]; then
+ NS_GUESS=$(kubectl config view --minify --context="$CONTEXT" -o jsonpath='{.contexts[0].context.namespace}')
+fi
+[ -z "$NS_GUESS" ] && NS_GUESS="default"
+export NS_GUESS
+
+# --- apply (skipped in checks-only mode) ---
+
+# Run one internal kubectl step, passing meaningful output through raw.
+# $1 |-separated error substrings to tolerate silently (exit nonzero but expected)
+# $2 |-separated stdout substrings that mean "nothing to do" (exit zero but uninteresting)
+# $3... kubectl args
+# Any other failure is fatal and shown raw on stderr. Any other success output is passed through.
+_kubectl_step() {
+ _err_ok="$1"
+ _empty_ok="$2"
+ shift 2
+ _out=$(kubectl "$@" 2>&1) || {
+ _old_ifs="$IFS"; IFS='|'
+ for _pat in $_err_ok; do
+ case "$_out" in *"$_pat"*) IFS="$_old_ifs"; return 0 ;; esac
+ done
+ IFS="$_old_ifs"
+ printf '%s\n' "$_out" >&2
+ return 1
+ }
+ [ -z "$_out" ] && return 0
+ _old_ifs="$IFS"; IFS='|'
+ for _pat in $_empty_ok; do
+ case "$_out" in *"$_pat"*) IFS="$_old_ifs"; return 0 ;; esac
+ done
+ IFS="$_old_ifs"
+ printf '%s\n' "$_out"
+}
+
+if [ "$MODE" = "apply" ]; then
+ DRY_RUN_FLAG=""
+ [ -n "$DRY_RUN" ] && DRY_RUN_FLAG="--dry-run=$DRY_RUN"
+
+ _kubectl_step 'AlreadyExists|no objects passed to create' '' \
+ $ctx create --save-config $DRY_RUN_FLAG --selector=yolean.se/converge-mode=create "$@"
+
+ # delete for replace-mode resources: under dry-run, kubectl itself simulates
+ # and prints "(dry run)" without actually deleting.
+ _kubectl_step '' 'No resources found' \
+ $ctx delete $DRY_RUN_FLAG --selector=yolean.se/converge-mode=replace "$@"
+
+ _kubectl_step 'no objects passed to apply' '' \
+ $ctx apply --server-side --force-conflicts $DRY_RUN_FLAG --selector=yolean.se/converge-mode=serverside-force "$@"
+ _kubectl_step 'no objects passed to apply' '' \
+ $ctx apply --server-side $DRY_RUN_FLAG --selector=yolean.se/converge-mode=serverside "$@"
+ _kubectl_step 'no objects passed to apply' '' \
+ $ctx apply $DRY_RUN_FLAG --selector='yolean.se/converge-mode!=create,yolean.se/converge-mode!=serverside,yolean.se/converge-mode!=serverside-force' "$@"
+fi
+
+# --- yconverge.cue: post-apply checks ---
+
+if [ -n "$yconverge_dir" ]; then
+ _run_checks() {
+ checks_json="$1"
+ label="$2"
+ [ -z "$checks_json" ] || [ "$checks_json" = "[]" ] && return 0
+ count=$(echo "$checks_json" | y-yq '. | length' -)
+ [ "$count" = "0" ] && return 0
+ i=0
+ while [ "$i" -lt "$count" ]; do
+ kind=$(echo "$checks_json" | y-yq ".[$i].kind" -)
+ desc=$(echo "$checks_json" | y-yq ".[$i].description // \"\"" -)
+ resource=$(echo "$checks_json" | y-yq ".[$i].resource // \"\"" -)
+ forcond=$(echo "$checks_json" | y-yq ".[$i].for // \"\"" -)
+ ns=$(echo "$checks_json" | y-yq ".[$i].namespace // \"\"" -)
+ timeout=$(echo "$checks_json" | y-yq ".[$i].timeout // \"60s\"" -)
+ command=$(echo "$checks_json" | y-yq ".[$i].command // \"\"" -)
+ [ -z "$ns" ] && ns="$NS_GUESS"
+ ns_flag=""
+ [ -n "$ns" ] && ns_flag="-n $ns"
+ case "$kind" in
+ wait)
+ echo " [yconverge] $label wait $resource $forcond"
+ kubectl --context="$CONTEXT" wait --for="$forcond" --timeout="$timeout" $ns_flag "$resource"
+ ;;
+ rollout)
+ echo " [yconverge] $label rollout $resource"
+ kubectl --context="$CONTEXT" rollout status --timeout="$timeout" $ns_flag "$resource"
+ ;;
+ exec)
+ echo " [yconverge] $label $desc"
+ _timeout_s=${timeout%s}
+ _deadline=$(($(date +%s) + _timeout_s))
+ _exec_ok=0
+ while :; do
+ if sh -c "$command"; then
+ _exec_ok=1
+ break
+ fi
+ [ "$(date +%s)" -ge "$_deadline" ] && break
+ sleep 2
+ done
+ if [ "$_exec_ok" = "0" ]; then
+ echo " [yconverge] ERROR: exec check failed after ${timeout}: $desc" >&2
+ return 1
+ fi
+ ;;
+ esac
+ i=$((i + 1))
+ done
+ }
+
+ CHECKS=$(y-cue eval "$yconverge_dir" -e 'step.checks' --out json) || {
+ echo " [yconverge] ERROR: failed to evaluate $yconverge_dir/yconverge.cue" >&2
+ exit 1
+ }
+ _run_checks "$CHECKS" "check:"
+fi
diff --git a/bin/y-bin.runner.yaml b/bin/y-bin.runner.yaml
index 7e66108a..af7d4b53 100755
--- a/bin/y-bin.runner.yaml
+++ b/bin/y-bin.runner.yaml
@@ -110,30 +110,27 @@ crane:
path: crane
esbuild:
- version: 0.25.11
+ version: 0.28.0
templates:
download: https://registry.npmjs.org/@esbuild/${os}-${xarm}64/-/${os}-${xarm}64-${version}.tgz
sha256:
- darwin_amd64: 6c685589690123a0a2b6424f82cfe21973e81923c0c61246bc48c69911f24dba
- darwin_arm64: 42ea27b04af4aaf177f5a6fa5cb6dda85d21ecd2fa45adb865cc3430eb985e2e
- linux_amd64: e9e893cfe6a327b97b8d749a2d477d7379ef87eb576b88e03e91b7d07ec935e0
- linux_arm64: 5bb8193032b55494841f27aa5bec41e6e1c717d6d90ae5651a4bd9ac01ebc930
+ darwin_amd64: 58716de460d4e258a56191c1bb47c7341c1ed61d8d4d92b8991c85907e117667
+ darwin_arm64: 6ef1261e245caed64e4d5195572e3468f18a5df4b65df5e14f9ae10408ad5502
+ linux_amd64: e94bf1c7f44197b22cf6a787578eca9af805aa9624104488252de2a765c6a4f0
+ linux_arm64: 9fdc4e2d6fac8e55b7ee3a69617d1fd4856d19eab72f8a150c5545c9f666973a
archive:
tool: tar
path: package/bin/esbuild
turbo:
- version: 2.8.17
+ version: 2.9.5-hashdepends.1
templates:
- download: https://registry.npmjs.org/turbo-${os}-${arm}64/-/turbo-${os}-${arm}64-${version}.tgz
+ download: https://github.com/solsson/turbo/releases/download/v${version}/turbo-${os}-${arch}
sha256:
- darwin_amd64: c1653788f3e82aac33c38a2cf0be8bb3c94069f50bf742826d17ab0fbcabd430
- darwin_arm64: 7bbcb3a0218702bfd43916828abcd42a892777f751c2c7b40a02f6730cf789ac
- linux_amd64: dcb1bac57f5745503defe968dc23780b2ad986615c2a4c9a44439061eb10753a
- linux_arm64: 8b20bfcd34adfbe8fb670653571b87947c882ee850c56969ebf6cd7d63a8192b
- archive:
- tool: tar
- path: turbo-${os}-${xarm}64/bin/turbo
+ darwin_amd64: 45ca441c683ec728d35056406de588b3d40dfbee0d2c6f2e1b5cf36c9b826843
+ darwin_arm64: c866d3f6ac4dc89233775cab233bdf886c0bc8f027cdeba96a897b0f90c7a5f8
+ linux_amd64: ac3b3ea6db7bf1b59ae7ac0024a0af649fc5543fac70aae2e4785ed8a4f0a27b
+ linux_arm64: c5f746d9a1a1628900ac464bb4af989c60a0e888898f68474ee58548fce4b690
yarn:
version: 0.2.1
@@ -145,6 +142,19 @@ yarn:
linux_amd64: 16252fe8ac0b3500bd697bc47213cc438209e2d5f8a812def075a0cdec891301
linux_arm64: 05ca3451a6f78a68b08c13b4b0b582cb22220b90ccd00160bffff80224d8c50d
+cue:
+ version: 0.16.0
+ templates:
+ download: https://github.com/cue-lang/cue/releases/download/v${version}/cue_v${version}_${os}_${arch}.tar.gz
+ sha256:
+ darwin_amd64: 451495b46684fd78120741e1d325f9b21e6843f05ec455d3fb5073ba2a9311db
+ darwin_arm64: 83ba7485cbad5f031ffc3e0142bc4f3ab26b53fd4491548939bfa9d5b00f6b66
+ linux_amd64: bbe0c5e85bc101cd1f95e68242f5ad48c3db73bad72be94fe2dc79d3f1ef0d4c
+ linux_arm64: 5c5a59bcb31d53c81feee58774752df14baef5c412138f49c208ab15608ac981
+ archive:
+ tool: tar
+ path: cue
+
npx:
version: 0.2.1
templates:
diff --git a/bin/y-cluster-converge-ystack b/bin/y-cluster-converge-ystack
index 03384ede..28f95aa1 100755
--- a/bin/y-cluster-converge-ystack
+++ b/bin/y-cluster-converge-ystack
@@ -2,162 +2,35 @@
[ -z "$DEBUG" ] || set -x
set -eo pipefail
+[ "$1" = "help" ] && echo '
+Converge all ystack infrastructure on a k3s cluster.
+Resolves dependencies from yconverge.cue imports automatically.
+
+Usage: y-cluster-converge-ystack --context= [--override-ip=IP]
+' && exit 0
+
YSTACK_HOME="$(cd "$(dirname "$0")/.." && pwd)"
CONTEXT=""
-EXCLUDE=""
OVERRIDE_IP=""
while [ $# -gt 0 ]; do
case "$1" in
--context=*) CONTEXT="${1#*=}"; shift ;;
- --exclude=*) EXCLUDE="${1#*=}"; shift ;;
--override-ip=*) OVERRIDE_IP="${1#*=}"; shift ;;
*) echo "Unknown flag: $1" >&2; exit 1 ;;
esac
done
-[ -z "$CONTEXT" ] && echo "Usage: y-cluster-converge-ystack --context= [--exclude=SUBSTRING] [--override-ip=IP]" && exit 1
-
-# Validate --exclude value matches a known namespace directory
-if [ -n "$EXCLUDE" ]; then
- EXCLUDE_VALID=false
- for ns_dir in "$YSTACK_HOME"/k3s/[0-9][0-9]-namespace-*/; do
- ns_name=$(basename "$ns_dir")
- ns_name="${ns_name#[0-9][0-9]-namespace-}"
- if [ "$EXCLUDE" = "$ns_name" ]; then
- EXCLUDE_VALID=true
- break
- fi
- done
- if [ "$EXCLUDE_VALID" = "false" ]; then
- echo "ERROR: --exclude=$EXCLUDE does not match any namespace in k3s/" >&2
- echo "Valid values:" >&2
- for ns_dir in "$YSTACK_HOME"/k3s/[0-9][0-9]-namespace-*/; do
- ns_name=$(basename "$ns_dir")
- echo " ${ns_name#[0-9][0-9]-namespace-}" >&2
- done
- exit 1
- fi
-fi
-
-k() {
- kubectl --context="$CONTEXT" "$@"
-}
-
-# HTTP requests to cluster services via the K8s API proxy (works regardless of provisioner)
-# Usage: kurl
-kurl() {
- local ns="$1" svc="$2" path="$3"
- k get --raw "/api/v1/namespaces/$ns/services/$svc:80/proxy/$path"
-}
-
-apply_base() {
- local base="$1"
- local output
- output=$(k apply -k "$YSTACK_HOME/k3s/$base/" 2>&1) || {
- echo "$output" >&2
- return 1
- }
- [ -n "$output" ] && echo "$output"
-}
-
-# List bases in order, filter out -disabled suffix
-echo "[y-cluster-converge-ystack] Listing bases"
-BASES=()
-for dir in "$YSTACK_HOME"/k3s/[0-9][0-9]-*/; do
- base=$(basename "$dir")
- if [[ "$base" == *-disabled ]]; then
- echo "[y-cluster-converge-ystack] Skipping disabled: $base"
- continue
- fi
- if [ -n "$EXCLUDE" ] && [[ "$base" == *"$EXCLUDE"* ]]; then
- echo "[y-cluster-converge-ystack] Skipping excluded (--exclude=$EXCLUDE): $base"
- continue
- fi
- BASES+=("$base")
-done
-echo "[y-cluster-converge-ystack] Bases: ${BASES[*]}"
-
-prev_digit=""
-for base in "${BASES[@]}"; do
- digit="${base:0:1}"
-
- # Between digit groups, wait for readiness
- if [ -n "$prev_digit" ] && [ "$digit" != "$prev_digit" ]; then
- echo "[y-cluster-converge-ystack] Waiting for rollouts after ${prev_digit}* bases"
-
- # After CRDs (1*), wait for all of them to be established
- if [ "$prev_digit" = "1" ]; then
- echo "[y-cluster-converge-ystack] Waiting for all CRDs to be established"
- k wait --for=condition=Established crd --all --timeout=60s
- fi
-
- # Wait for all deployments that exist in any namespace
- for ns in $(k get deploy --all-namespaces --no-headers -o custom-columns=NS:.metadata.namespace 2>/dev/null | sort -u); do
- echo "[y-cluster-converge-ystack] Waiting for deployments in $ns"
- k -n "$ns" rollout status deploy --timeout=120s
- done
-
- # After 2* (gateway + y-kustomize), update /etc/hosts so curl can reach services
- if [ "$prev_digit" = "2" ]; then
- if [ -n "$OVERRIDE_IP" ]; then
- echo "[y-cluster-converge-ystack] Annotating gateway with yolean.se/override-ip=$OVERRIDE_IP"
- k -n ystack annotate gateway ystack yolean.se/override-ip="$OVERRIDE_IP" --overwrite
- fi
- if ! "$YSTACK_HOME/bin/y-k8s-ingress-hosts" --context="$CONTEXT" --ensure; then
- echo "[y-cluster-converge-ystack] WARNING: /etc/hosts update failed (may need manual sudo)" >&2
- fi
- fi
-
- # After 4* (kafka secrets updated), restart y-kustomize so volume mounts refresh
- # without waiting for kubelet sync (can take 60-120s)
- if [ "$prev_digit" = "4" ]; then
- echo "[y-cluster-converge-ystack] Restarting y-kustomize to pick up updated secrets"
- k -n ystack rollout restart deploy/y-kustomize
- k -n ystack rollout status deploy/y-kustomize --timeout=60s
- fi
-
- # Before 6* bases, verify y-kustomize serves real content
- # Check via API proxy first, then via Traefik (port 80) which is the path kustomize uses
- if [ "$digit" = "6" ]; then
- echo "[y-cluster-converge-ystack] Verifying y-kustomize API"
- kurl ystack y-kustomize health >/dev/null
- echo "[y-cluster-converge-ystack] y-kustomize health ok (via API proxy)"
- # Verify the Traefik route works (this is the path kustomize uses for HTTP resources)
- curl -sSf --retry 5 --retry-delay 2 --retry-all-errors --connect-timeout 2 --max-time 5 \
- http://y-kustomize.ystack.svc.cluster.local/v1/blobs/setup-bucket-job/base-for-annotations.yaml >/dev/null
- echo "[y-cluster-converge-ystack] y-kustomize serving blobs bases (via Traefik)"
- curl -sSf --retry 5 --retry-delay 2 --retry-all-errors --connect-timeout 2 --max-time 5 \
- http://y-kustomize.ystack.svc.cluster.local/v1/kafka/setup-topic-job/base-for-annotations.yaml >/dev/null
- echo "[y-cluster-converge-ystack] y-kustomize serving kafka bases (via Traefik)"
- fi
- fi
-
- echo "[y-cluster-converge-ystack] Applying $base"
- if [[ "$base" == 1* ]]; then
- k apply -k "$YSTACK_HOME/k3s/$base/" --server-side=true --force-conflicts
- else
- apply_base "$base"
- fi
-
- prev_digit="$digit"
-done
+[ -z "$CONTEXT" ] && echo "Usage: y-cluster-converge-ystack --context= [--override-ip=IP]" && exit 1
-# Update /etc/hosts now that all routes exist
-if ! "$YSTACK_HOME/bin/y-k8s-ingress-hosts" --context="$CONTEXT" --ensure; then
- echo "[y-cluster-converge-ystack] WARNING: /etc/hosts update failed (may need manual sudo)" >&2
-fi
+export OVERRIDE_IP
-# Validation
-echo "[y-cluster-converge-ystack] Validation"
-k -n ystack get gateway ystack
-k -n ystack get deploy y-kustomize
-k -n blobs get svc y-s3-api
-k -n kafka get statefulset redpanda
-CLUSTER_IP=$(k -n ystack get svc builds-registry -o=jsonpath='{.spec.clusterIP}' 2>/dev/null || echo "")
-if [ -n "$CLUSTER_IP" ] && [ "$CLUSTER_IP" != "10.43.0.50" ]; then
- echo "[y-cluster-converge-ystack] WARNING: builds-registry clusterIP is $CLUSTER_IP, expected 10.43.0.50" >&2
-fi
+cd "$YSTACK_HOME"
-echo "[y-cluster-converge-ystack] Completed. To verify use: y-cluster-validate-ystack --context=$CONTEXT"
+# Converge all leaf targets. Each resolves its own dependency chain.
+# Shared dependencies are idempotent — re-applying is a no-op.
+kubectl-yconverge --context="$CONTEXT" -k k3s/62-buildkit/
+kubectl-yconverge --context="$CONTEXT" -k k3s/50-monitoring/
+kubectl-yconverge --context="$CONTEXT" -k k3s/61-prod-registry/
+kubectl-yconverge --context="$CONTEXT" -k k3s/40-kafka/
diff --git a/bin/y-cluster-provision-qemu b/bin/y-cluster-provision-qemu
index 919f6f76..0daf25f5 100755
--- a/bin/y-cluster-provision-qemu
+++ b/bin/y-cluster-provision-qemu
@@ -36,7 +36,8 @@ Flags:
--exclude=SUBSTRING exclude k3s bases matching substring (default: monitoring)
--skip-converge skip converge and post-provision steps
--skip-image-load skip image cache and load into containerd
- --teardown stop and delete the VM
+ --teardown stop and delete the VM (removes disk by default)
+ --keep-disk preserve disk image on teardown for faster re-provision
--export-vmdk=PATH export disk as streamOptimized VMDK
Prerequisites:
@@ -53,6 +54,7 @@ EOF
--skip-converge) SKIP_CONVERGE=true; shift ;;
--skip-image-load) SKIP_IMAGE_LOAD=true; shift ;;
--teardown) TEARDOWN=true; shift ;;
+ --keep-disk) KEEP_DISK=true; shift ;;
--export-vmdk=*) EXPORT_VMDK="${1#*=}"; shift ;;
*) echo "Unknown flag: $1" >&2; exit 1 ;;
esac
@@ -116,7 +118,12 @@ if [ "$TEARDOWN" = "true" ]; then
rm -f "$VM_PIDFILE"
fi
kubectl config delete-context $CTX 2>/dev/null || true
- echo "[y-cluster-provision-qemu] Teardown complete. Disk preserved at $VM_DISK"
+ if [ "$KEEP_DISK" = "true" ]; then
+ echo "[y-cluster-provision-qemu] Teardown complete. Disk preserved at $VM_DISK"
+ else
+ rm -f "$VM_DISK"
+ echo "[y-cluster-provision-qemu] Teardown complete. Disk deleted."
+ fi
exit 0
fi
diff --git a/bin/y-cue b/bin/y-cue
new file mode 100755
index 00000000..1e818963
--- /dev/null
+++ b/bin/y-cue
@@ -0,0 +1,8 @@
+#!/bin/sh
+[ -z "$DEBUG" ] || set -x
+set -e
+YBIN="$(dirname $0)"
+
+version=$(y-bin-download $YBIN/y-bin.runner.yaml cue)
+
+y-cue-v${version}-bin "$@" || exit $?
diff --git a/cue.mod/module.cue b/cue.mod/module.cue
new file mode 100644
index 00000000..10e646fd
--- /dev/null
+++ b/cue.mod/module.cue
@@ -0,0 +1,4 @@
+module: "yolean.se/ystack"
+language: {
+ version: "v0.16.0"
+}
diff --git a/k3s/00-namespace-ystack/yconverge.cue b/k3s/00-namespace-ystack/yconverge.cue
new file mode 100644
index 00000000..e78dc7da
--- /dev/null
+++ b/k3s/00-namespace-ystack/yconverge.cue
@@ -0,0 +1,7 @@
+package namespace_ystack
+
+import "yolean.se/ystack/yconverge/verify"
+
+step: verify.#Step & {
+ checks: []
+}
diff --git a/k3s/01-namespace-blobs/yconverge.cue b/k3s/01-namespace-blobs/yconverge.cue
new file mode 100644
index 00000000..2be32ca0
--- /dev/null
+++ b/k3s/01-namespace-blobs/yconverge.cue
@@ -0,0 +1,7 @@
+package namespace_blobs
+
+import "yolean.se/ystack/yconverge/verify"
+
+step: verify.#Step & {
+ checks: []
+}
diff --git a/k3s/02-namespace-kafka/yconverge.cue b/k3s/02-namespace-kafka/yconverge.cue
new file mode 100644
index 00000000..5ee5cc2a
--- /dev/null
+++ b/k3s/02-namespace-kafka/yconverge.cue
@@ -0,0 +1,7 @@
+package namespace_kafka
+
+import "yolean.se/ystack/yconverge/verify"
+
+step: verify.#Step & {
+ checks: []
+}
diff --git a/k3s/03-namespace-monitoring/yconverge.cue b/k3s/03-namespace-monitoring/yconverge.cue
new file mode 100644
index 00000000..dfe009ca
--- /dev/null
+++ b/k3s/03-namespace-monitoring/yconverge.cue
@@ -0,0 +1,7 @@
+package namespace_monitoring
+
+import "yolean.se/ystack/yconverge/verify"
+
+step: verify.#Step & {
+ checks: []
+}
diff --git a/k3s/09-y-kustomize-secrets-init/yconverge.cue b/k3s/09-y-kustomize-secrets-init/yconverge.cue
new file mode 100644
index 00000000..bb62908e
--- /dev/null
+++ b/k3s/09-y-kustomize-secrets-init/yconverge.cue
@@ -0,0 +1,12 @@
+package y_kustomize_secrets_init
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/00-namespace-ystack:namespace_ystack"
+)
+
+_dep_ns: namespace_ystack.step
+
+step: verify.#Step & {
+ checks: []
+}
diff --git a/k3s/10-gateway-api/kustomization.yaml b/k3s/10-gateway-api/kustomization.yaml
index 195509f2..a36bb860 100644
--- a/k3s/10-gateway-api/kustomization.yaml
+++ b/k3s/10-gateway-api/kustomization.yaml
@@ -1,5 +1,7 @@
# yaml-language-server: $schema=https://json.schemastore.org/kustomization.json
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
+commonLabels:
+ yolean.se/converge-mode: serverside-force
resources:
- traefik-gateway-provider.yaml
diff --git a/k3s/10-gateway-api/yconverge.cue b/k3s/10-gateway-api/yconverge.cue
new file mode 100644
index 00000000..6c1daa66
--- /dev/null
+++ b/k3s/10-gateway-api/yconverge.cue
@@ -0,0 +1,17 @@
+package gateway_api
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/00-namespace-ystack:namespace_ystack"
+)
+
+_dep_ns: namespace_ystack.step
+
+step: verify.#Step & {
+ checks: [{
+ kind: "exec"
+ command: "for i in $(seq 1 30); do kubectl --context=$CONTEXT wait --for=condition=Established --timeout=2s crd/gateways.gateway.networking.k8s.io 2>/dev/null && break; sleep 2; done && kubectl --context=$CONTEXT wait --for=condition=Established --timeout=5s crd/gateways.gateway.networking.k8s.io"
+ timeout: "120s"
+ description: "gateway API CRDs established"
+ }]
+}
diff --git a/k3s/11-monitoring-operator/kustomization.yaml b/k3s/11-monitoring-operator/kustomization.yaml
index fe1e4dfd..682dcdda 100644
--- a/k3s/11-monitoring-operator/kustomization.yaml
+++ b/k3s/11-monitoring-operator/kustomization.yaml
@@ -1,5 +1,7 @@
# yaml-language-server: $schema=https://json.schemastore.org/kustomization.json
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
+commonLabels:
+ yolean.se/converge-mode: serverside-force
resources:
- ../../monitoring/prometheus-operator
diff --git a/k3s/11-monitoring-operator/yconverge.cue b/k3s/11-monitoring-operator/yconverge.cue
new file mode 100644
index 00000000..5cd6a67d
--- /dev/null
+++ b/k3s/11-monitoring-operator/yconverge.cue
@@ -0,0 +1,17 @@
+package monitoring_operator
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/03-namespace-monitoring:namespace_monitoring"
+)
+
+_dep_ns: namespace_monitoring.step
+
+step: verify.#Step & {
+ checks: [{
+ kind: "rollout"
+ resource: "deploy/prometheus-operator"
+ namespace: "default"
+ timeout: "120s"
+ }]
+}
diff --git a/k3s/20-gateway/yconverge.cue b/k3s/20-gateway/yconverge.cue
new file mode 100644
index 00000000..2f98541d
--- /dev/null
+++ b/k3s/20-gateway/yconverge.cue
@@ -0,0 +1,32 @@
+package gateway
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/10-gateway-api:gateway_api"
+)
+
+_dep_crds: gateway_api.step
+
+step: verify.#Step & {
+ checks: [
+ {
+ kind: "exec"
+ command: "[ -z \"$OVERRIDE_IP\" ] || kubectl --context=$CONTEXT -n ystack annotate gateway ystack yolean.se/override-ip=$OVERRIDE_IP --overwrite"
+ timeout: "10s"
+ description: "annotate gateway with override-ip (if set)"
+ },
+ {
+ kind: "exec"
+ command: "y-k8s-ingress-hosts --context=$CONTEXT --ensure || echo 'WARNING: /etc/hosts update failed (may need manual sudo)'"
+ timeout: "10s"
+ description: "update /etc/hosts for gateway routes"
+ },
+ {
+ kind: "wait"
+ resource: "gateway/ystack"
+ namespace: "ystack"
+ for: "condition=Programmed"
+ timeout: "60s"
+ },
+ ]
+}
diff --git a/k3s/29-y-kustomize/yconverge.cue b/k3s/29-y-kustomize/yconverge.cue
new file mode 100644
index 00000000..f51f685e
--- /dev/null
+++ b/k3s/29-y-kustomize/yconverge.cue
@@ -0,0 +1,19 @@
+package y_kustomize
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/09-y-kustomize-secrets-init:y_kustomize_secrets_init"
+ "yolean.se/ystack/k3s/20-gateway:gateway"
+)
+
+_dep_secrets: y_kustomize_secrets_init.step
+_dep_gateway: gateway.step
+
+step: verify.#Step & {
+ checks: [{
+ kind: "rollout"
+ resource: "deploy/y-kustomize"
+ namespace: "ystack"
+ timeout: "120s"
+ }]
+}
diff --git a/k3s/30-blobs-minio-disabled/yconverge.cue b/k3s/30-blobs-minio-disabled/yconverge.cue
new file mode 100644
index 00000000..f8ba675e
--- /dev/null
+++ b/k3s/30-blobs-minio-disabled/yconverge.cue
@@ -0,0 +1,7 @@
+package blobs_minio_disabled
+
+import "yolean.se/ystack/yconverge/verify"
+
+step: verify.#Step & {
+ checks: []
+}
diff --git a/k3s/30-blobs-ystack/yconverge.cue b/k3s/30-blobs-ystack/yconverge.cue
new file mode 100644
index 00000000..75bed634
--- /dev/null
+++ b/k3s/30-blobs-ystack/yconverge.cue
@@ -0,0 +1,19 @@
+package blobs_ystack
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/01-namespace-blobs:namespace_blobs"
+ "yolean.se/ystack/k3s/29-y-kustomize:y_kustomize"
+)
+
+_dep_ns: namespace_blobs.step
+_dep_kustomize: y_kustomize.step
+
+step: verify.#Step & {
+ checks: [{
+ kind: "exec"
+ command: "kubectl --context=$CONTEXT -n ystack rollout restart deploy/y-kustomize && kubectl --context=$CONTEXT -n ystack rollout status deploy/y-kustomize --timeout=60s"
+ timeout: "90s"
+ description: "restart y-kustomize to pick up blobs secrets"
+ }]
+}
diff --git a/k3s/30-blobs/yconverge.cue b/k3s/30-blobs/yconverge.cue
new file mode 100644
index 00000000..fc31b65f
--- /dev/null
+++ b/k3s/30-blobs/yconverge.cue
@@ -0,0 +1,17 @@
+package blobs
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/30-blobs-ystack:blobs_ystack"
+)
+
+_dep_ystack: blobs_ystack.step
+
+step: verify.#Step & {
+ checks: [{
+ kind: "rollout"
+ resource: "deploy/versitygw"
+ namespace: "blobs"
+ timeout: "60s"
+ }]
+}
diff --git a/k3s/40-kafka-ystack/yconverge.cue b/k3s/40-kafka-ystack/yconverge.cue
new file mode 100644
index 00000000..abefc9b7
--- /dev/null
+++ b/k3s/40-kafka-ystack/yconverge.cue
@@ -0,0 +1,45 @@
+package kafka_ystack
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/02-namespace-kafka:namespace_kafka"
+ "yolean.se/ystack/k3s/29-y-kustomize:y_kustomize"
+)
+
+_dep_ns: namespace_kafka.step
+_dep_kustomize: y_kustomize.step
+
+step: verify.#Step & {
+ checks: [
+ {
+ kind: "exec"
+ command: "kubectl --context=$CONTEXT -n ystack rollout restart deploy/y-kustomize && kubectl --context=$CONTEXT -n ystack rollout status deploy/y-kustomize --timeout=60s"
+ timeout: "90s"
+ description: "restart y-kustomize to pick up kafka secrets"
+ },
+ {
+ kind: "exec"
+ command: "kubectl --context=$CONTEXT get --raw /api/v1/namespaces/ystack/services/y-kustomize:80/proxy/v1/blobs/setup-bucket-job/base-for-annotations.yaml"
+ timeout: "60s"
+ description: "y-kustomize serving blobs bases (API proxy)"
+ },
+ {
+ kind: "exec"
+ command: "kubectl --context=$CONTEXT get --raw /api/v1/namespaces/ystack/services/y-kustomize:80/proxy/v1/kafka/setup-topic-job/base-for-annotations.yaml"
+ timeout: "60s"
+ description: "y-kustomize serving kafka bases (API proxy)"
+ },
+ {
+ kind: "exec"
+ command: "curl -sSf --connect-timeout 2 --max-time 5 http://y-kustomize.ystack.svc.cluster.local/v1/blobs/setup-bucket-job/base-for-annotations.yaml >/dev/null"
+ timeout: "60s"
+ description: "y-kustomize serving blobs bases (Traefik)"
+ },
+ {
+ kind: "exec"
+ command: "curl -sSf --connect-timeout 2 --max-time 5 http://y-kustomize.ystack.svc.cluster.local/v1/kafka/setup-topic-job/base-for-annotations.yaml >/dev/null"
+ timeout: "60s"
+ description: "y-kustomize serving kafka bases (Traefik)"
+ },
+ ]
+}
diff --git a/k3s/40-kafka/yconverge.cue b/k3s/40-kafka/yconverge.cue
new file mode 100644
index 00000000..bbf63a6f
--- /dev/null
+++ b/k3s/40-kafka/yconverge.cue
@@ -0,0 +1,25 @@
+package kafka
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/40-kafka-ystack:kafka_ystack"
+)
+
+_dep_ystack: kafka_ystack.step
+
+step: verify.#Step & {
+ checks: [
+ {
+ kind: "rollout"
+ resource: "statefulset/redpanda"
+ namespace: "kafka"
+ timeout: "120s"
+ },
+ {
+ kind: "exec"
+ command: "kubectl --context=$CONTEXT exec -n kafka redpanda-0 -c redpanda -- rpk cluster info"
+ timeout: "30s"
+ description: "redpanda cluster healthy"
+ },
+ ]
+}
diff --git a/k3s/50-monitoring/yconverge.cue b/k3s/50-monitoring/yconverge.cue
new file mode 100644
index 00000000..9b8a3a9f
--- /dev/null
+++ b/k3s/50-monitoring/yconverge.cue
@@ -0,0 +1,17 @@
+package monitoring
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/11-monitoring-operator:monitoring_operator"
+)
+
+_dep_operator: monitoring_operator.step
+
+step: verify.#Step & {
+ checks: [{
+ kind: "rollout"
+ resource: "deploy/kube-state-metrics"
+ namespace: "monitoring"
+ timeout: "60s"
+ }]
+}
diff --git a/k3s/60-builds-registry/yconverge.cue b/k3s/60-builds-registry/yconverge.cue
new file mode 100644
index 00000000..4b75a860
--- /dev/null
+++ b/k3s/60-builds-registry/yconverge.cue
@@ -0,0 +1,29 @@
+package builds_registry
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/30-blobs:blobs"
+ "yolean.se/ystack/k3s/40-kafka-ystack:kafka_ystack"
+ "yolean.se/ystack/k3s/29-y-kustomize:y_kustomize"
+)
+
+_dep_blobs: blobs.step
+_dep_kafka: kafka_ystack.step
+_dep_kustomize: y_kustomize.step
+
+step: verify.#Step & {
+ checks: [
+ {
+ kind: "rollout"
+ resource: "deploy/registry"
+ namespace: "ystack"
+ timeout: "60s"
+ },
+ {
+ kind: "exec"
+ command: "kubectl --context=$CONTEXT get --raw /api/v1/namespaces/ystack/services/builds-registry:80/proxy/v2/_catalog"
+ timeout: "30s"
+ description: "registry v2 API responds"
+ },
+ ]
+}
diff --git a/k3s/61-prod-registry/yconverge.cue b/k3s/61-prod-registry/yconverge.cue
new file mode 100644
index 00000000..5285b073
--- /dev/null
+++ b/k3s/61-prod-registry/yconverge.cue
@@ -0,0 +1,12 @@
+package prod_registry
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/00-namespace-ystack:namespace_ystack"
+)
+
+_dep_ns: namespace_ystack.step
+
+step: verify.#Step & {
+ checks: []
+}
diff --git a/k3s/62-buildkit/yconverge.cue b/k3s/62-buildkit/yconverge.cue
new file mode 100644
index 00000000..f8709636
--- /dev/null
+++ b/k3s/62-buildkit/yconverge.cue
@@ -0,0 +1,17 @@
+package buildkit
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/k3s/60-builds-registry:builds_registry"
+)
+
+_dep_registry: builds_registry.step
+
+step: verify.#Step & {
+ checks: [{
+ kind: "exec"
+ command: "kubectl --context=$CONTEXT -n ystack get statefulset buildkitd"
+ timeout: "10s"
+ description: "buildkitd statefulset exists"
+ }]
+}
diff --git a/runner.Dockerfile b/runner.Dockerfile
index 984fcc17..e71231a8 100644
--- a/runner.Dockerfile
+++ b/runner.Dockerfile
@@ -80,6 +80,9 @@ RUN y-esbuild --version
COPY bin/y-turbo /usr/local/src/ystack/bin/
RUN y-turbo --version
+COPY bin/y-cue /usr/local/src/ystack/bin/
+RUN y-cue version
+
FROM --platform=$TARGETPLATFORM base
COPY --from=node --link /usr/local/lib/node_modules /usr/local/lib/node_modules
diff --git a/yconverge/itest/cluster-prod/db/kustomization.yaml b/yconverge/itest/cluster-prod/db/kustomization.yaml
new file mode 100644
index 00000000..575a1403
--- /dev/null
+++ b/yconverge/itest/cluster-prod/db/kustomization.yaml
@@ -0,0 +1,9 @@
+# yaml-language-server: $schema=https://json.schemastore.org/kustomization.json
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: db
+
+resources:
+- ../../example-db/distributed
+- pdb.yaml
diff --git a/yconverge/itest/cluster-prod/db/pdb.yaml b/yconverge/itest/cluster-prod/db/pdb.yaml
new file mode 100644
index 00000000..3a66a37f
--- /dev/null
+++ b/yconverge/itest/cluster-prod/db/pdb.yaml
@@ -0,0 +1,9 @@
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: database
+spec:
+ minAvailable: 2
+ selector:
+ matchLabels:
+ app: database
diff --git a/yconverge/itest/cluster-qa/db/kustomization.yaml b/yconverge/itest/cluster-qa/db/kustomization.yaml
new file mode 100644
index 00000000..e7e809fa
--- /dev/null
+++ b/yconverge/itest/cluster-qa/db/kustomization.yaml
@@ -0,0 +1,8 @@
+# yaml-language-server: $schema=https://json.schemastore.org/kustomization.json
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: db
+
+resources:
+- ../../example-db/single
diff --git a/yconverge/itest/example-configmap/configmap.yaml b/yconverge/itest/example-configmap/configmap.yaml
new file mode 100644
index 00000000..1f0e5e9c
--- /dev/null
+++ b/yconverge/itest/example-configmap/configmap.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: itest-config
+data:
+ key: value
diff --git a/yconverge/itest/example-configmap/kustomization.yaml b/yconverge/itest/example-configmap/kustomization.yaml
new file mode 100644
index 00000000..a29fc9b2
--- /dev/null
+++ b/yconverge/itest/example-configmap/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: itest
+resources:
+- configmap.yaml
diff --git a/yconverge/itest/example-configmap/yconverge.cue b/yconverge/itest/example-configmap/yconverge.cue
new file mode 100644
index 00000000..be155404
--- /dev/null
+++ b/yconverge/itest/example-configmap/yconverge.cue
@@ -0,0 +1,17 @@
+package example_configmap
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/yconverge/itest/example-namespace:example_namespace"
+)
+
+_dep_ns: example_namespace.step
+
+step: verify.#Step & {
+ checks: [{
+ kind: "exec"
+ command: "kubectl --context=$CONTEXT -n itest get configmap itest-config"
+ timeout: "10s"
+ description: "configmap exists"
+ }]
+}
diff --git a/yconverge/itest/example-db/base/db-service.yaml b/yconverge/itest/example-db/base/db-service.yaml
new file mode 100644
index 00000000..a1b08a48
--- /dev/null
+++ b/yconverge/itest/example-db/base/db-service.yaml
@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: Service
+metadata:
+ name: db
+spec:
+ selector:
+ app: database
+ ports: []
+ clusterIP: None
diff --git a/yconverge/itest/example-db/base/db-statefulset.yaml b/yconverge/itest/example-db/base/db-statefulset.yaml
new file mode 100644
index 00000000..13910d8f
--- /dev/null
+++ b/yconverge/itest/example-db/base/db-statefulset.yaml
@@ -0,0 +1,17 @@
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: database
+spec:
+ selector:
+ matchLabels:
+ app: database
+ serviceName: "db"
+ template:
+ metadata:
+ labels:
+ app: database
+ spec:
+ containers:
+ - name: server
+ image: ghcr.io/yolean/static-web-server:2.41.0@sha256:34bb160fd62d2145dabd0598f36352653ec58cf80a8d58c8cd2617097d34564d
diff --git a/yconverge/itest/example-db/base/kustomization.yaml b/yconverge/itest/example-db/base/kustomization.yaml
new file mode 100644
index 00000000..62864bc9
--- /dev/null
+++ b/yconverge/itest/example-db/base/kustomization.yaml
@@ -0,0 +1,9 @@
+# yaml-language-server: $schema=https://json.schemastore.org/kustomization.json
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: ONLY_apply_through_cluster_variant
+
+resources:
+- db-service.yaml
+- db-statefulset.yaml
diff --git a/yconverge/itest/example-db/checks/checks.cue b/yconverge/itest/example-db/checks/checks.cue
new file mode 100644
index 00000000..ede9a72d
--- /dev/null
+++ b/yconverge/itest/example-db/checks/checks.cue
@@ -0,0 +1,13 @@
+package checks
+
+// Parameterized check set for the database statefulset.
+// Variants (single, distributed) import and unify with their own replica count.
+#DbChecks: {
+ replicas: int
+ list: [{
+ kind: "wait"
+ resource: "statefulset/database"
+ for: "jsonpath={.status.currentReplicas}=\(replicas)"
+ timeout: "30s"
+ }]
+}
diff --git a/yconverge/itest/example-db/distributed/kustomization.yaml b/yconverge/itest/example-db/distributed/kustomization.yaml
new file mode 100644
index 00000000..0a06bfe9
--- /dev/null
+++ b/yconverge/itest/example-db/distributed/kustomization.yaml
@@ -0,0 +1,12 @@
+# yaml-language-server: $schema=https://json.schemastore.org/kustomization.json
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: ONLY_apply_through_cluster_variant
+
+resources:
+- ../base
+
+replicas:
+- name: database
+ count: 3
diff --git a/yconverge/itest/example-db/distributed/yconverge.cue b/yconverge/itest/example-db/distributed/yconverge.cue
new file mode 100644
index 00000000..ac122c94
--- /dev/null
+++ b/yconverge/itest/example-db/distributed/yconverge.cue
@@ -0,0 +1,12 @@
+package example_db_distributed
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/yconverge/itest/example-db/checks"
+)
+
+_shared: checks.#DbChecks & {replicas: 3}
+
+step: verify.#Step & {
+ checks: _shared.list
+}
diff --git a/yconverge/itest/example-db/namespace/db-namespace.yaml b/yconverge/itest/example-db/namespace/db-namespace.yaml
new file mode 100644
index 00000000..bab604e0
--- /dev/null
+++ b/yconverge/itest/example-db/namespace/db-namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: db
diff --git a/yconverge/itest/example-db/namespace/kustomization.yaml b/yconverge/itest/example-db/namespace/kustomization.yaml
new file mode 100644
index 00000000..e8102663
--- /dev/null
+++ b/yconverge/itest/example-db/namespace/kustomization.yaml
@@ -0,0 +1,6 @@
+# yaml-language-server: $schema=https://json.schemastore.org/kustomization.json
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+- db-namespace.yaml
diff --git a/yconverge/itest/example-db/single/kustomization.yaml b/yconverge/itest/example-db/single/kustomization.yaml
new file mode 100644
index 00000000..99b63e75
--- /dev/null
+++ b/yconverge/itest/example-db/single/kustomization.yaml
@@ -0,0 +1,8 @@
+# yaml-language-server: $schema=https://json.schemastore.org/kustomization.json
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: ONLY_apply_through_cluster_variant
+
+resources:
+- ../base
diff --git a/yconverge/itest/example-db/single/yconverge.cue b/yconverge/itest/example-db/single/yconverge.cue
new file mode 100644
index 00000000..d2df3307
--- /dev/null
+++ b/yconverge/itest/example-db/single/yconverge.cue
@@ -0,0 +1,18 @@
+package example_db_single
+
+import (
+ "list"
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/yconverge/itest/example-db/checks"
+)
+
+_shared: checks.#DbChecks & {replicas: 1}
+
+step: verify.#Step & {
+ checks: list.Concat([_shared.list, [{
+ kind: "exec"
+ command: #"kubectl --context=$CONTEXT -n $NS_GUESS get pdb -o jsonpath='{.items[*].spec.minAvailable}' | tr ' ' '\n' | awk '$1 > 1 { exit 1 }'"#
+ description: "no PDB requires more than 1 replica (single-replica safety)"
+ timeout: "5s"
+ }]])
+}
diff --git a/yconverge/itest/example-disabled/configmap.yaml b/yconverge/itest/example-disabled/configmap.yaml
new file mode 100644
index 00000000..16a78576
--- /dev/null
+++ b/yconverge/itest/example-disabled/configmap.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: itest-should-not-exist
+data:
+ disabled: "true"
diff --git a/yconverge/itest/example-disabled/kustomization.yaml b/yconverge/itest/example-disabled/kustomization.yaml
new file mode 100644
index 00000000..a29fc9b2
--- /dev/null
+++ b/yconverge/itest/example-disabled/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: itest
+resources:
+- configmap.yaml
diff --git a/yconverge/itest/example-disabled/yconverge.cue b/yconverge/itest/example-disabled/yconverge.cue
new file mode 100644
index 00000000..8de2101b
--- /dev/null
+++ b/yconverge/itest/example-disabled/yconverge.cue
@@ -0,0 +1,12 @@
+package example_disabled
+
+import "yolean.se/ystack/yconverge/verify"
+
+step: verify.#Step & {
+ checks: [{
+ kind: "exec"
+ command: "false"
+ timeout: "5s"
+ description: "should never run"
+ }]
+}
diff --git a/yconverge/itest/example-indirect/kustomization.yaml b/yconverge/itest/example-indirect/kustomization.yaml
new file mode 100644
index 00000000..49829b97
--- /dev/null
+++ b/yconverge/itest/example-indirect/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- ../example-configmap
diff --git a/yconverge/itest/example-namespace/kustomization.yaml b/yconverge/itest/example-namespace/kustomization.yaml
new file mode 100644
index 00000000..c313b540
--- /dev/null
+++ b/yconverge/itest/example-namespace/kustomization.yaml
@@ -0,0 +1,4 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- namespace.yaml
diff --git a/yconverge/itest/example-namespace/namespace.yaml b/yconverge/itest/example-namespace/namespace.yaml
new file mode 100644
index 00000000..a751051b
--- /dev/null
+++ b/yconverge/itest/example-namespace/namespace.yaml
@@ -0,0 +1,4 @@
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: itest
diff --git a/yconverge/itest/example-namespace/yconverge.cue b/yconverge/itest/example-namespace/yconverge.cue
new file mode 100644
index 00000000..cd042904
--- /dev/null
+++ b/yconverge/itest/example-namespace/yconverge.cue
@@ -0,0 +1,12 @@
+package example_namespace
+
+import "yolean.se/ystack/yconverge/verify"
+
+step: verify.#Step & {
+ checks: [{
+ kind: "wait"
+ resource: "ns/itest"
+ for: "jsonpath={.status.phase}=Active"
+ timeout: "10s"
+ }]
+}
diff --git a/yconverge/itest/example-replace/job.yaml b/yconverge/itest/example-replace/job.yaml
new file mode 100644
index 00000000..63edc04d
--- /dev/null
+++ b/yconverge/itest/example-replace/job.yaml
@@ -0,0 +1,13 @@
+apiVersion: batch/v1
+kind: Job
+metadata:
+ name: example-replace-job
+ labels:
+ yolean.se/converge-mode: replace
+spec:
+ template:
+ spec:
+ restartPolicy: Never
+ containers:
+ - name: noop
+ image: ghcr.io/yolean/static-web-server:2.41.0@sha256:34bb160fd62d2145dabd0598f36352653ec58cf80a8d58c8cd2617097d34564d
diff --git a/yconverge/itest/example-replace/kustomization.yaml b/yconverge/itest/example-replace/kustomization.yaml
new file mode 100644
index 00000000..37b594f5
--- /dev/null
+++ b/yconverge/itest/example-replace/kustomization.yaml
@@ -0,0 +1,8 @@
+# yaml-language-server: $schema=https://json.schemastore.org/kustomization.json
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+namespace: default
+
+resources:
+- job.yaml
diff --git a/yconverge/itest/example-serverside/configmap.yaml b/yconverge/itest/example-serverside/configmap.yaml
new file mode 100644
index 00000000..b3f5159f
--- /dev/null
+++ b/yconverge/itest/example-serverside/configmap.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: itest-serverside
+data:
+ applied: via-serverside-force
diff --git a/yconverge/itest/example-serverside/kustomization.yaml b/yconverge/itest/example-serverside/kustomization.yaml
new file mode 100644
index 00000000..b05b1265
--- /dev/null
+++ b/yconverge/itest/example-serverside/kustomization.yaml
@@ -0,0 +1,7 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: itest
+commonLabels:
+ yolean.se/converge-mode: serverside-force
+resources:
+- configmap.yaml
diff --git a/yconverge/itest/example-with-dependency/configmap.yaml b/yconverge/itest/example-with-dependency/configmap.yaml
new file mode 100644
index 00000000..578b3839
--- /dev/null
+++ b/yconverge/itest/example-with-dependency/configmap.yaml
@@ -0,0 +1,6 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: itest-dependent
+data:
+ depends-on: itest-config
diff --git a/yconverge/itest/example-with-dependency/kustomization.yaml b/yconverge/itest/example-with-dependency/kustomization.yaml
new file mode 100644
index 00000000..a29fc9b2
--- /dev/null
+++ b/yconverge/itest/example-with-dependency/kustomization.yaml
@@ -0,0 +1,5 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: itest
+resources:
+- configmap.yaml
diff --git a/yconverge/itest/example-with-dependency/yconverge.cue b/yconverge/itest/example-with-dependency/yconverge.cue
new file mode 100644
index 00000000..c31ead37
--- /dev/null
+++ b/yconverge/itest/example-with-dependency/yconverge.cue
@@ -0,0 +1,17 @@
+package example_with_dependency
+
+import (
+ "yolean.se/ystack/yconverge/verify"
+ "yolean.se/ystack/yconverge/itest/example-configmap:example_configmap"
+)
+
+_dep_config: example_configmap.step
+
+step: verify.#Step & {
+ checks: [{
+ kind: "exec"
+ command: "kubectl --context=$CONTEXT -n itest get configmap itest-dependent"
+ timeout: "10s"
+ description: "dependent configmap exists"
+ }]
+}
diff --git a/yconverge/itest/test.sh b/yconverge/itest/test.sh
new file mode 100755
index 00000000..7bec0dd9
--- /dev/null
+++ b/yconverge/itest/test.sh
@@ -0,0 +1,237 @@
+#!/usr/bin/env bash
+[ -z "$DEBUG" ] || set -x
+set -eo pipefail
+
+[ "$1" = "help" ] && echo '
+Integration tests for the yconverge framework.
+Uses kwok (registry.k8s.io/kwok/cluster) as a lightweight test cluster.
+
+Flags:
+ --keep keep the kwok cluster running after tests
+ --teardown remove a kept cluster and exit
+
+Requires: docker, kubectl, y-cue, kubectl-yconverge
+' && exit 0
+
+KEEP=false
+TEARDOWN=false
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --keep) KEEP=true; shift ;;
+ --teardown) TEARDOWN=true; shift ;;
+ *) echo "Unknown flag: $1" >&2; exit 1 ;;
+ esac
+done
+
+# Remove a docker container, tolerating only the "not there" case.
+_docker_rm_tolerant() {
+ _name="$1"
+ if ! _out=$(docker rm -f "$_name" 2>&1); then
+ case "$_out" in
+ *"No such container"*) ;;
+ *) echo "[cue itest] warn: docker rm $_name: $_out" >&2 ;;
+ esac
+ fi
+}
+
+if [ "$TEARDOWN" = "true" ]; then
+ echo "[cue itest] Tearing down kept cluster ..."
+ _docker_rm_tolerant yconverge-itest
+ rm -f /tmp/ystack-yconverge-itest
+ echo "[cue itest] Done"
+ exit 0
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+YSTACK_HOME="$(cd "$SCRIPT_DIR/../.." && pwd)"
+CTX="yconverge-itest"
+
+if [ "$KEEP" = "true" ]; then
+ CONTAINER_NAME="yconverge-itest"
+ ITEST_KUBECONFIG="/tmp/ystack-yconverge-itest"
+else
+ CONTAINER_NAME="yconverge-itest-$$"
+ ITEST_KUBECONFIG=$(mktemp /tmp/ystack-yconverge-itest.XXXXXX)
+fi
+export KUBECONFIG="$ITEST_KUBECONFIG"
+
+cleanup() {
+ if [ "$KEEP" = "true" ]; then
+ echo "[cue itest] KEEP=true, cluster kept:"
+ echo " KUBECONFIG=$ITEST_KUBECONFIG kubectl --context=$CTX get ns"
+ return
+ fi
+ echo "[cue itest] Cleaning up ..."
+ _docker_rm_tolerant "$CONTAINER_NAME"
+ rm -f "$ITEST_KUBECONFIG"
+}
+trap cleanup EXIT
+
+echo "[cue itest] yconverge framework integration tests"
+
+# --- start kwok cluster ---
+
+echo "[cue itest] Starting kwok cluster ..."
+docker run -d --name "$CONTAINER_NAME" \
+ -p 0:8080 \
+ registry.k8s.io/kwok/cluster:v0.7.0-k8s.v1.33.0
+PORT=$(docker port "$CONTAINER_NAME" 8080 | head -1 | cut -d: -f2)
+
+for i in $(seq 1 30); do
+ kubectl --server="http://127.0.0.1:$PORT" get ns default >/dev/null 2>&1 && break
+ sleep 1
+done
+
+kubectl config set-cluster "$CTX" --server="http://127.0.0.1:$PORT" >/dev/null
+kubectl config set-context "$CTX" --cluster="$CTX" >/dev/null
+kubectl config set-credentials "$CTX" >/dev/null
+kubectl config set-context "$CTX" --user="$CTX" >/dev/null
+kubectl config use-context "$CTX" >/dev/null
+kubectl --context="$CTX" get ns default >/dev/null 2>&1 \
+ && echo "[cue itest] kwok cluster ready at port $PORT" \
+ || { echo "[cue itest] FATAL: kwok cluster not reachable"; exit 1; }
+
+# kwok --manage-all-nodes=true only manages nodes that already exist. Without a
+# node, pods stay Pending ("no nodes available to schedule pods") and StatefulSet
+# status.currentReplicas never advances past the OrderedReady gate. Create one
+# fake node so pod-ready stages fire and replica counts reflect spec.
+kubectl --context="$CTX" apply -f - <<'YAML' >/dev/null
+apiVersion: v1
+kind: Node
+metadata:
+ name: kwok-node-0
+ labels:
+ kubernetes.io/hostname: kwok-node-0
+ type: kwok
+status:
+ capacity: { cpu: "32", memory: 256Gi, pods: "110" }
+ allocatable: { cpu: "32", memory: 256Gi, pods: "110" }
+YAML
+
+export CONTEXT="$CTX"
+
+cd "$YSTACK_HOME"
+
+echo "[cue itest] Ensuring tool binaries are available ..."
+y-cue version >/dev/null
+y-yq --version >/dev/null
+kubectl version --client=true >/dev/null 2>&1
+
+# --- schema validation ---
+
+echo ""
+echo "[cue itest] CUE schema validation"
+y-cue vet ./yconverge/itest/example-namespace/
+y-cue vet ./yconverge/itest/example-configmap/
+y-cue vet ./yconverge/itest/example-with-dependency/
+y-cue vet ./yconverge/itest/example-disabled/
+y-cue vet ./yconverge/itest/example-db/single/
+y-cue vet ./yconverge/itest/example-db/distributed/
+
+# --- apply with auto-checks ---
+
+echo ""
+echo "[cue itest] Apply with auto-checks (namespace)"
+kubectl-yconverge --context="$CTX" -k yconverge/itest/example-namespace/
+
+echo ""
+echo "[cue itest] Apply with checks (configmap depends on namespace)"
+kubectl-yconverge --context="$CTX" -k yconverge/itest/example-configmap/
+
+echo ""
+echo "[cue itest] Transitive dependency (depends on configmap which depends on namespace)"
+kubectl-yconverge --context="$CTX" -k yconverge/itest/example-with-dependency/
+
+# --- indirection with namespace from referenced base ---
+
+echo ""
+echo "[cue itest] Indirection: yconverge.cue and namespace from referenced base"
+kubectl-yconverge --context="$CTX" -k yconverge/itest/example-indirect/
+
+# --- idempotent re-converge ---
+
+echo ""
+echo "[cue itest] Idempotent re-apply"
+kubectl-yconverge --context="$CTX" -k yconverge/itest/example-namespace/
+kubectl-yconverge --context="$CTX" -k yconverge/itest/example-configmap/
+
+# --- converge-mode labels ---
+
+echo ""
+echo "[cue itest] Serverside-force label (other selectors match nothing)"
+kubectl-yconverge --context="$CTX" --skip-checks -k yconverge/itest/example-serverside/
+kubectl-yconverge --context="$CTX" --skip-checks -k yconverge/itest/example-serverside/
+
+echo ""
+echo "[cue itest] replace-mode under --dry-run=server must not delete anything"
+kubectl-yconverge --context="$CTX" --skip-checks -k yconverge/itest/example-replace/
+_REPLACE_UID_BEFORE=$(kubectl --context="$CTX" -n default get job example-replace-job -o jsonpath='{.metadata.uid}')
+_REPLACE_DRY_OUT=$(mktemp /tmp/yconverge-itest-replace.XXXXXX)
+kubectl-yconverge --context="$CTX" --skip-checks --dry-run=server -k yconverge/itest/example-replace/ 2>&1 | tee "$_REPLACE_DRY_OUT"
+grep -q '(server dry run)' "$_REPLACE_DRY_OUT"
+_REPLACE_UID_AFTER=$(kubectl --context="$CTX" -n default get job example-replace-job -o jsonpath='{.metadata.uid}')
+[ "$_REPLACE_UID_BEFORE" = "$_REPLACE_UID_AFTER" ] \
+ || { echo "[cue itest] FAIL: dry-run deleted/recreated the replace-mode Job (uid $_REPLACE_UID_BEFORE -> $_REPLACE_UID_AFTER)"; exit 1; }
+kubectl --context="$CTX" -n default delete job example-replace-job >/dev/null
+rm -f "$_REPLACE_DRY_OUT"
+
+_OUT=$(mktemp /tmp/yconverge-itest-out.XXXXXX)
+
+# --- assert: indirection output shows referenced path ---
+
+echo ""
+echo "[cue itest] Indirection output must reference the base directory"
+kubectl-yconverge --context="$CTX" -k yconverge/itest/example-indirect/ 2>&1 | tee "$_OUT"
+grep -q "example-configmap/yconverge.cue" "$_OUT"
+
+# --- negative: --skip-checks suppresses check invocation ---
+
+echo ""
+echo "[cue itest] --skip-checks must not produce [yconverge] output"
+kubectl-yconverge --context="$CTX" --skip-checks -k yconverge/itest/example-namespace/ 2>&1 | tee "$_OUT"
+! grep -q "\[yconverge\]" "$_OUT"
+
+# --- negative: broken yconverge.cue must fail ---
+
+echo ""
+echo "[cue itest] Broken yconverge.cue must fail with error message"
+mkdir -p /tmp/yconverge-itest-broken
+cat > /tmp/yconverge-itest-broken/kustomization.yaml << 'YAML'
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- configmap.yaml
+YAML
+cat > /tmp/yconverge-itest-broken/configmap.yaml << 'YAML'
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: broken-test
+ namespace: default
+data: {}
+YAML
+cat > /tmp/yconverge-itest-broken/yconverge.cue << 'CUE'
+package broken
+this_is_not_valid_cue: !!!
+CUE
+! kubectl-yconverge --context="$CTX" -k /tmp/yconverge-itest-broken/ 2>&1 | tee "$_OUT"
+grep -q "ERROR" "$_OUT"
+rm -rf /tmp/yconverge-itest-broken
+
+rm -f "$_OUT"
+
+# --- prod/qa kustomize example ---
+
+# never include namespaces in actual bases as it makes delete -k irreversibe in many cases
+kubectl yconverge --context="$CTX" -k yconverge/itest/example-db/namespace/
+kubectl yconverge --context="$CTX" -k yconverge/itest/cluster-prod/db/
+
+# cluster-qa/db asserts that no PDB requires more than 1 replica. Applying prod
+# first left a PDB with minAvailable: 2 in the namespace, so remove it before
+# running qa — recovery step, not a framework feature.
+kubectl --context="$CTX" -n db delete pdb database
+
+kubectl yconverge --context="$CTX" -k yconverge/itest/cluster-qa/db/
+
+echo ""
+echo "[cue itest] All tests passed"
diff --git a/yconverge/verify/schema.cue b/yconverge/verify/schema.cue
new file mode 100644
index 00000000..febdbb65
--- /dev/null
+++ b/yconverge/verify/schema.cue
@@ -0,0 +1,56 @@
+package verify
+
+// A convergence step: apply a kustomize base, then verify.
+// The yconverge.cue file must be next to a kustomization.yaml.
+// The kustomization path is implicit from the file location.
+#Step: {
+ // Checks that must pass after apply.
+ // Empty list means the step is ready immediately after apply.
+ checks: [...#Check]
+ // True after apply + checks complete successfully.
+ // Downstream steps that import this package gate on this value.
+ // Set by the engine, not by user CUE files.
+ up: *false | bool
+ // Namespace derived by the engine from:
+ // 1. -n CLI arg to kubectl-yconverge
+ // 2. referenced base's kustomization.yaml namespace: (when indirection is in effect)
+ // 3. kustomization.yaml namespace: field
+ // 4. kubectl context default namespace
+ // Used as default for #Wait/#Rollout checks that omit namespace.
+ // Set by the engine, not by user CUE files.
+ namespaceGuess: *"" | string
+}
+
+// Check is a discriminated union. Each variant maps to a kubectl
+// subcommand that manages its own timeout and output.
+#Check: #Wait | #Rollout | #Exec
+
+// Thin wrapper around kubectl wait.
+// Timeout and output are managed by kubectl.
+#Wait: {
+ kind: "wait"
+ resource: string
+ for: string
+ namespace?: string
+ timeout: *"60s" | string
+ description: *"" | string
+}
+
+// Thin wrapper around kubectl rollout status.
+// Timeout and output are managed by kubectl.
+#Rollout: {
+ kind: "rollout"
+ resource: string
+ namespace?: string
+ timeout: *"60s" | string
+ description: *"" | string
+}
+
+// Arbitrary command for checks that don't map to kubectl builtins.
+// The engine retries until timeout.
+#Exec: {
+ kind: "exec"
+ command: string
+ timeout: *"60s" | string
+ description: string
+}