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 +}