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/bin/kubectl-yconverge b/bin/kubectl-yconverge new file mode 100755 index 00000000..58064849 --- /dev/null +++ b/bin/kubectl-yconverge @@ -0,0 +1,359 @@ +#!/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|-h|help) + _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 via kustomize-traverse --- +# Walks the full kustomization directory tree and returns all dirs +# that contain a yconverge.cue file. + +_find_cue_dirs() { + d="$1" + y-kustomize-traverse -q -o dirs "$d" | while read -r rel; do + abs="$d/$rel" + if [ -f "$abs/yconverge.cue" ]; then + echo "$abs" + fi + done +} + +# --- 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() { + [ -f "$1" ] || return 0 + grep '"yolean.se/ystack/' "$1" \ + | grep -v '"yolean.se/ystack/yconverge/verify"' \ + | sed 's|.*"yolean.se/ystack/\([^":]*\).*|\1|' \ + || : +} + +_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_dirs "${1%/}" | tail -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" | wc -l) + + if [ "$MODE" = "print-deps" ]; then + printf '%s\n' "$deps" + exit 0 + fi + + if [ "$dep_count" -gt 1 ]; 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 files and resolve namespace --- + +yconverge_dirs="" +if [ -n "$KUSTOMIZE_DIR" ]; then + case "$MODE" in + apply) + [ "$SKIP_CHECKS" = "false" ] && yconverge_dirs=$(_find_cue_dirs "$KUSTOMIZE_DIR") + ;; + checks-only) + yconverge_dirs=$(_find_cue_dirs "$KUSTOMIZE_DIR") + [ -z "$yconverge_dirs" ] && _die "--checks-only: no yconverge.cue found for $KUSTOMIZE_DIR" + ;; + esac +fi + +for _d in $yconverge_dirs; do + echo " [yconverge] found $_d/yconverge.cue" +done + +# --- resolve namespace --- +# Priority: 1. -n CLI arg 2. kustomize-traverse 3. 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" ]; then + NS_GUESS=$(y-kustomize-traverse -q -o namespace "$KUSTOMIZE_DIR") +fi +if [ -z "$NS_GUESS" ]; then + NS_GUESS=$(kubectl config view --minify --context="$CONTEXT" -o jsonpath='{.contexts[0].context.namespace}' 2>/dev/null) || : +fi +[ -z "$NS_GUESS" ] && NS_GUESS="default" +export NAMESPACE="$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_dirs" ]; 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="$NAMESPACE" + 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 + } + + for yconverge_dir in $yconverge_dirs; do + case "$yconverge_dir" in + ./*|/*) ;; + *) yconverge_dir="./$yconverge_dir" ;; + esac + CHECKS=$(y-cue export "$yconverge_dir" -e 'step.checks') || { + echo " [yconverge] ERROR: failed to evaluate $yconverge_dir/yconverge.cue" >&2 + exit 1 + } + _run_checks "$CHECKS" "check:" + done +fi diff --git a/bin/y-bin.runner.yaml b/bin/y-bin.runner.yaml index ae23f11f..714f3855 100755 --- a/bin/y-bin.runner.yaml +++ b/bin/y-bin.runner.yaml @@ -155,6 +155,29 @@ cue: tool: tar path: cue +contain: + version: 0.8.0 + templates: + download: https://github.com/turbokube/contain/releases/download/v${version}/contain-v${version}-${os}-${arch} + sha256: + darwin_amd64: f1bf0e8a8ac055a57d7db3db847de2f375cb1bceeecbb3e3a17bda2c8ef227df + darwin_arm64: 0de02c17ed5bd013ff3f0335f51a41a2ab7d1ae2e14f2c4d94f8ee85943a2495 + linux_amd64: 3ae1b2fa80c66ae113c23cbe5d5f31456eccaf37723cd2944a9cdd880ebd1b72 + linux_arm64: 4a920ec5956acfde430c2efdb5043a6aec65fb20eb5fc2b9f961b60c6505ce7c + +kustomize-traverse: + version: 0.1.0 + templates: + download: https://github.com/Yolean/kustomize-traverse/releases/download/v${version}/kustomize-traverse-${os}-${arch}.tar.gz + sha256: + darwin_amd64: bdca1fe29afcbc9817557046a3de2661f9ce5044aec3086a263e2724200bb580 + darwin_arm64: 67acdd588a37cb213afad319ef18b67090214ee1d3bad06a469137cb5ef2b2b8 + linux_amd64: e643fe6a162ef22ef8ecffc960e0fc6c76741613098b3f583c16d9206a4f3628 + linux_arm64: d5e564c54d043350e928fb366a4ab004b09381e1aa3f07c750b598bc2bf2b85c + archive: + tool: tar + path: kustomize-traverse + npx: version: 0.2.1 templates: diff --git a/bin/y-cluster-converge-ystack b/bin/y-cluster-converge-ystack index 03384ede..328aaf9d 100755 --- a/bin/y-cluster-converge-ystack +++ b/bin/y-cluster-converge-ystack @@ -2,162 +2,57 @@ [ -z "$DEBUG" ] || set -x set -eo pipefail +[ "$1" = "help" ] && echo ' +Converge ystack infrastructure on a k3s cluster. +Resolves dependencies from yconverge.cue imports automatically. + +Usage: y-cluster-converge-ystack --context= [flags] + +Flags: + --converge=LIST comma-separated base names to converge (default: y-kustomize,blobs,builds-registry) + names are matched to k3s/ subdirs without number prefix + available: y-kustomize, blobs, builds-registry, kafka, buildkit, monitoring, prod-registry + --override-ip=IP override IP for gateway/ingress + --dry-run=MODE forward to kubectl-yconverge (server|none) +' && exit 0 + YSTACK_HOME="$(cd "$(dirname "$0")/.." && pwd)" CONTEXT="" -EXCLUDE="" OVERRIDE_IP="" +CONVERGE_TARGETS="${CONVERGE_TARGETS:-y-kustomize,blobs,builds-registry}" +DRY_RUN="" while [ $# -gt 0 ]; do case "$1" in - --context=*) CONTEXT="${1#*=}"; shift ;; - --exclude=*) EXCLUDE="${1#*=}"; shift ;; + --context=*) CONTEXT="${1#*=}"; shift ;; + --converge=*) CONVERGE_TARGETS="${1#*=}"; shift ;; --override-ip=*) OVERRIDE_IP="${1#*=}"; shift ;; + --dry-run=*) DRY_RUN="$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 +[ -z "$CONTEXT" ] && echo "Usage: y-cluster-converge-ystack --context= [--converge=LIST]" && exit 1 + +export OVERRIDE_IP -# 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 +_resolve_target() { + for d in "$YSTACK_HOME"/k3s/*/; do + local base="${d%/}" + base="${base##*/}" # strip path prefix + base="${base#[0-9][0-9]-}" # strip number prefix (e.g. 40-) + if [ "$base" = "$1" ]; then + echo "$d" + return 0 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" + return 1 } -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" +for target in $(echo "$CONVERGE_TARGETS" | tr ',' ' '); do + dir=$(_resolve_target "$target") + [ -n "$dir" ] || { echo "Unknown converge target: $target" >&2; exit 1; } + echo "# converge $target ($dir)" + kubectl-yconverge --context="$CONTEXT" $DRY_RUN -k "$dir" done - -# 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 - -# 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 - -echo "[y-cluster-converge-ystack] Completed. To verify use: y-cluster-validate-ystack --context=$CONTEXT" diff --git a/bin/y-cluster-local-ctr b/bin/y-cluster-local-ctr index 3933eac7..20fbf24b 100755 --- a/bin/y-cluster-local-ctr +++ b/bin/y-cluster-local-ctr @@ -14,4 +14,7 @@ case "$PROVISIONER" in lima) limactl shell ystack sudo k3s ctr "$@" ;; + qemu) + ssh -p 2222 -i "$HOME/.cache/ystack-qemu/ystack-qemu-ssh" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ystack@localhost sudo k3s ctr "$@" + ;; esac diff --git a/bin/y-cluster-provision-k3d b/bin/y-cluster-provision-k3d index 9baa6595..71b97965 100755 --- a/bin/y-cluster-provision-k3d +++ b/bin/y-cluster-provision-k3d @@ -14,7 +14,7 @@ K3D_AGENTS="0" K3D_DOCKER_UPDATE="--cpuset-cpus=3 --cpus=3" SKIP_CONVERGE=false SKIP_IMAGE_LOAD=false -EXCLUDE=monitoring +CONVERGE_TARGETS="y-kustomize,blobs,builds-registry" while [ $# -gt 0 ]; do case "$1" in @@ -28,7 +28,7 @@ Flags: --agents=N number of agent nodes (default: 0) --docker-update=ARGS docker update flags for the server container (default: --cpuset-cpus=3 --cpus=3) --host=HOSTNAME hostname for ingress (default: ystack.local) - --exclude=SUBSTRING exclude k3s bases matching substring (default: monitoring) + --converge=LIST comma-separated k3s bases to converge (default: y-kustomize,blobs,builds-registry) --skip-converge skip converge, validate, and post-provision steps --skip-image-load skip image cache and load into containerd --teardown delete existing cluster and exit @@ -40,7 +40,7 @@ EOF --agents=*) K3D_AGENTS="${1#*=}"; shift ;; --docker-update=*) K3D_DOCKER_UPDATE="${1#*=}"; shift ;; --host=*) YSTACK_HOST="${1#*=}"; shift ;; - --exclude=*) EXCLUDE="${1#*=}"; shift ;; + --converge=*) CONVERGE_TARGETS="${1#*=}"; shift ;; --skip-converge) SKIP_CONVERGE=true; shift ;; --skip-image-load) SKIP_IMAGE_LOAD=true; shift ;; --teardown) TEARDOWN=true; shift ;; @@ -119,6 +119,11 @@ sed -e 's/name: k3d-ystack/name: ystack-k3d/g' \ echo "# Waiting for API server to be ready ..." until kubectl --context=$CTX get nodes >/dev/null 2>&1; do sleep 2; done +# Gateway API is always set up, even with --skip-converge. +export OVERRIDE_IP=${YSTACK_PORTS_IP:-127.0.0.1} +kubectl yconverge --context=$CTX -k "$YSTACK_HOME/k3s/10-gateway-api/" +kubectl yconverge --context=$CTX -k "$YSTACK_HOME/k3s/20-gateway/" + if [ "$SKIP_CONVERGE" = "true" ]; then echo "# --skip-converge: skipping converge, validate, and post-provision steps" exit 0 @@ -134,10 +139,7 @@ else y-image-cache-load-all > /etc/hosts" -docker exec k3d-ystack-server-0 sh -cex "echo '$PROD_REGISTRY_IP prod-registry.ystack.svc.cluster.local' >> /etc/hosts" +# Registry resolution uses magic ClusterIPs in registries.yaml — no /etc/hosts needed. +# TODO: add containerd registry access verification (like qemu provisioner) diff --git a/bin/y-cluster-provision-qemu b/bin/y-cluster-provision-qemu index 0daf25f5..ae2b962e 100755 --- a/bin/y-cluster-provision-qemu +++ b/bin/y-cluster-provision-qemu @@ -15,7 +15,7 @@ VM_CPUS="4" VM_SSH_PORT="2222" SKIP_CONVERGE=false SKIP_IMAGE_LOAD=false -EXCLUDE=monitoring +CONVERGE_TARGETS="y-kustomize,blobs,builds-registry" while [ $# -gt 0 ]; do case "$1" in @@ -33,7 +33,7 @@ Flags: --memory=MB memory in MB (default: 8192) --cpus=N CPU count (default: 4) --ssh-port=PORT host port forwarded to VM ssh (default: 2222) - --exclude=SUBSTRING exclude k3s bases matching substring (default: monitoring) + --converge=LIST comma-separated k3s bases to converge (default: y-kustomize,blobs,builds-registry) --skip-converge skip converge and post-provision steps --skip-image-load skip image cache and load into containerd --teardown stop and delete the VM (removes disk by default) @@ -50,7 +50,7 @@ EOF --memory=*) VM_MEMORY="${1#*=}"; shift ;; --cpus=*) VM_CPUS="${1#*=}"; shift ;; --ssh-port=*) VM_SSH_PORT="${1#*=}"; shift ;; - --exclude=*) EXCLUDE="${1#*=}"; shift ;; + --converge=*) CONVERGE_TARGETS="${1#*=}"; shift ;; --skip-converge) SKIP_CONVERGE=true; shift ;; --skip-image-load) SKIP_IMAGE_LOAD=true; shift ;; --teardown) TEARDOWN=true; shift ;; @@ -242,6 +242,12 @@ sed -i 's/name: default/name: ystack-qemu/g; s/cluster: default/cluster: ystack- y-kubeconfig-import "$KUBECONFIG.tmp" +# Gateway API is always set up, even with --skip-converge. +# Services are reachable via port-forward at 127.0.0.1. +export OVERRIDE_IP=127.0.0.1 +kubectl yconverge --context=$CTX -k "$YSTACK_HOME/k3s/10-gateway-api/" +kubectl yconverge --context=$CTX -k "$YSTACK_HOME/k3s/20-gateway/" + if [ "$SKIP_CONVERGE" = "true" ]; then echo "[y-cluster-provision-qemu] --skip-converge: done" exit 0 @@ -251,19 +257,23 @@ if [ "$SKIP_IMAGE_LOAD" = "true" ]; then echo "[y-cluster-provision-qemu] --skip-image-load: skipping" else echo "[y-cluster-provision-qemu] Loading images ..." - y-image-cache-ystack > /etc/hosts'" -ssh_vm "sudo sh -c 'echo \"$PROD_REGISTRY_IP prod-registry.ystack.svc.cluster.local\" >> /etc/hosts'" +y-cluster-converge-ystack --context=$CTX --converge=$CONVERGE_TARGETS --override-ip=127.0.0.1 + +# Verify containerd can reach registries via mirror config (magic ClusterIPs) +echo "[y-cluster-provision-qemu] Verifying containerd registry access ..." +for reg in builds-registry prod-registry; do + if echo "$CONVERGE_TARGETS" | tr ',' '\n' | grep -q "$reg"; then + ssh_vm "curl -sf http://$(kubectl --context=$CTX -n ystack get service $reg -o=jsonpath='{.spec.clusterIP}')/v2/ >/dev/null" \ + && echo " $reg: OK" \ + || { echo " $reg: FAIL — containerd cannot reach registry" >&2; exit 1; } + fi +done echo "[y-cluster-provision-qemu] Done. SSH: ssh -p $VM_SSH_PORT -i $VM_SSH_KEY ystack@localhost" echo "[y-cluster-provision-qemu] Export: y-cluster-provision-qemu --export-vmdk=appliance.vmdk" diff --git a/bin/y-contain b/bin/y-contain index 0909efa6..56d3784b 100755 --- a/bin/y-contain +++ b/bin/y-contain @@ -3,6 +3,6 @@ set -e YBIN="$(dirname $0)" -version=$(y-bin-download $YBIN/y-bin.optional.yaml contain) +version=$(y-bin-download $YBIN/y-bin.runner.yaml contain) y-contain-v${version}-bin "$@" || exit $? diff --git a/bin/y-image-cache-load b/bin/y-image-cache-load index 7cac3bd1..5c958608 100755 --- a/bin/y-image-cache-load +++ b/bin/y-image-cache-load @@ -2,6 +2,19 @@ [ -z "$DEBUG" ] || set -x set -eo pipefail +[ "$1" = "help" ] && echo ' +Load a cached OCI image into the local cluster containerd. + +Usage: y-image-cache-load + +The image must be cached at: + ${XDG_CACHE_HOME:-$HOME/.cache}/ystack-image-cache/oci//index.json + +Use y-image-cache-save to populate the cache from a registry. + +Supports k3d, qemu, and multipass provisioners. +' && exit 0 + [ -z "$1" ] && echo "Usage: y-image-cache-load " >&2 && exit 1 IMAGE_REF="$1" @@ -58,11 +71,11 @@ if [[ "$ANNOTATED_REF" == *@sha256:* ]]; then FULL_TAG_REF="docker.io/$FULL_TAG_REF" fi echo "# Tagging tag ref: $FULL_TAG_REF" - y-cluster-local-ctr images tag "$ANNOTATED_REF" "$FULL_TAG_REF" 2>/dev/null || true + y-cluster-local-ctr images tag "$ANNOTATED_REF" "$FULL_TAG_REF" 2>/dev/null || true # y-script-lint:disable=or-true # tag may already exist fi else REPO="${ANNOTATED_REF%:*}" DIGEST_REF="${REPO}@${CACHED_DIGEST}" echo "# Tagging digest ref: $DIGEST_REF" - y-cluster-local-ctr images tag "$ANNOTATED_REF" "$DIGEST_REF" 2>/dev/null || true + y-cluster-local-ctr images tag "$ANNOTATED_REF" "$DIGEST_REF" 2>/dev/null || true # y-script-lint:disable=or-true # tag may already exist fi diff --git a/bin/y-image-cache-ystack b/bin/y-image-cache-ystack index 88d7e6f8..ef41d3e6 100755 --- a/bin/y-image-cache-ystack +++ b/bin/y-image-cache-ystack @@ -2,7 +2,7 @@ [ -z "$DEBUG" ] || set -x set -eo pipefail -y-image-list-ystack | while read -r image; do +y-image-list-ystack "$@" | while read -r image; do [ -z "$image" ] && continue y-image-cache-save "$image" done diff --git a/bin/y-image-list-ystack b/bin/y-image-list-ystack index c15a13fa..08235f97 100755 --- a/bin/y-image-list-ystack +++ b/bin/y-image-list-ystack @@ -4,11 +4,25 @@ set -eo pipefail YSTACK_HOME="$(cd "$(dirname "$0")/.." && pwd)" -# Converge bases from y-cluster-converge-ystack BASES array -BASES=$(sed -n '/^BASES=(/,/^)/{ /^BASES=(/d; /^)/d; s/^[[:space:]]*//; p; }' "$YSTACK_HOME/bin/y-cluster-converge-ystack") -for base in $BASES; do - kubectl kustomize "$YSTACK_HOME/k3s/$base/" 2>/dev/null \ - | grep -oE 'image:\s*\S+' \ - | sed 's/image:[[:space:]]*//' \ - || true +[ "$1" = "help" ] && echo ' +Lists container images used by ystack converge targets. +Uses the same --converge syntax as y-cluster-converge-ystack. + +Usage: y-image-list-ystack [--converge=LIST] +' && exit 0 + +CONVERGE_TARGETS="${1#--converge=}" +[ -n "$CONVERGE_TARGETS" ] || CONVERGE_TARGETS="${CONVERGE_TARGETS:-y-kustomize,blobs,builds-registry}" + +for target in $(echo "$CONVERGE_TARGETS" | tr ',' ' '); do + for d in "$YSTACK_HOME"/k3s/*/; do + base="${d%/}" + base="${base##*/}" + base="${base#[0-9][0-9]-}" + [ "$base" = "$target" ] || continue + kubectl kustomize "$d" \ + | grep -oE 'image:\s*\S+' \ + | sed 's/image:[[:space:]]*//' + break + done done | sort -u diff --git a/bin/y-k3s-install b/bin/y-k3s-install index 7a17c3b3..f1f5c873 100755 --- a/bin/y-k3s-install +++ b/bin/y-k3s-install @@ -11,7 +11,7 @@ export K3S_NODE_NAME=ystack-master export INSTALL_K3S_EXEC="--kubelet-arg=address=0.0.0.0 ${INSTALL_K3S_EXEC}" INSTALLER_REVISION=50fa2d70c239b3984dab99a2fb1ddaa35c3f2051 -export INSTALL_K3S_VERSION=v1.35.1+k3s1 +export INSTALL_K3S_VERSION=v1.35.3+k3s1 curl -sfL https://github.com/k3s-io/k3s/raw/$INSTALLER_REVISION/install.sh | sh - service k3s start diff --git a/bin/y-k8s-ingress-hosts b/bin/y-k8s-ingress-hosts index b10529cc..7db5d27b 100755 --- a/bin/y-k8s-ingress-hosts +++ b/bin/y-k8s-ingress-hosts @@ -89,6 +89,19 @@ if $CHECK || $ENSURE; then PASSTHROUGH+=("-write") fi +# Guard: don't write an empty block that clears existing entries. +# Preview without -write to check if there are entries. +_PREVIEW_ARGS=() +for _a in "${PASSTHROUGH[@]}"; do + [ "$_a" = "-write" ] || _PREVIEW_ARGS+=("$_a") +done +echo "# reading k8s ingress resources..." +_PREVIEW=$($YBIN/y-k8s-ingress-hosts-v${version}-bin -kubeconfig "$CONTEXT_KUBECONFIG" "${_PREVIEW_ARGS[@]}" 2>/dev/null | grep -v '^#') +if [ -z "$_PREVIEW" ]; then + echo "# no ingress/gateway entries found, skipping write to preserve existing /etc/hosts" + exit 0 +fi + [ $(id -u) -ne 0 ] && exec sudo $YBIN/y-k8s-ingress-hosts-v${version}-bin -kubeconfig "$CONTEXT_KUBECONFIG" "${PASSTHROUGH[@]}" $YBIN/y-k8s-ingress-hosts-v${version}-bin -kubeconfig "$CONTEXT_KUBECONFIG" "${PASSTHROUGH[@]}" || exit $? diff --git a/bin/y-kustomize-traverse b/bin/y-kustomize-traverse new file mode 100755 index 00000000..6e23a6fa --- /dev/null +++ b/bin/y-kustomize-traverse @@ -0,0 +1,8 @@ +#!/bin/sh +[ -z "$DEBUG" ] || set -x +set -e +YBIN="$(dirname $0)" + +version=$(y-bin-download $YBIN/y-bin.runner.yaml kustomize-traverse) + +y-kustomize-traverse-v${version}-bin "$@" || exit $? diff --git a/bin/y-registry-config b/bin/y-registry-config index 284198a3..bc96b448 100755 --- a/bin/y-registry-config +++ b/bin/y-registry-config @@ -25,14 +25,20 @@ YSTACK_PROD_REGISTRY=europe-west3-docker.pkg.dev YSTACK_PROD_REGISTRY_TEST_IMAGE YSTACK_PROD_REGISTRY_PROTOCOL="https" [ "$YSTACK_PROD_REGISTRY" != prod-registry.ystack.svc.cluster.local ] || [ "$YSTACK_PROD_REGISTRY_INSECURE" = "false" ] || YSTACK_PROD_REGISTRY_PROTOCOL="http" +# ClusterIPs are fixed via builds-registry-magic-numbers.yaml and prod-registry-magic-numbers.yaml. +# Using IPs instead of hostnames avoids needing /etc/hosts hacks on the node. +YSTACK_HOME="$(cd "$(dirname "$0")/.." && pwd)" +BUILDS_REGISTRY_IP=$(y-yq '.spec.clusterIP' "$YSTACK_HOME/k3s/60-builds-registry/builds-registry-magic-numbers.yaml") +PROD_REGISTRY_IP=$(y-yq '.spec.clusterIP' "$YSTACK_HOME/k3s/61-prod-registry/prod-registry-magic-numbers.yaml") + cat </dev/null | grep -qE ':80 |:443 ' && echo "port 80 and 443 must be available for local cluster to bind to" && exit 1 -y-cluster-provision-k3d + +y-cluster-provision --skip-converge + +# --- progressive convergence: proves DAG resolves deps without include/exclude --- + +echo "" +echo "# Phase 1: base platform (registry + y-kustomize serving)" +kubectl yconverge --context=local -k k3s/60-builds-registry/ + +echo "" +echo "# Phase 2: kafka stack (transitive deps through y-kustomize)" +kubectl yconverge --context=local -k k3s/40-kafka/ + +echo "" +echo "# Phase 3: build infra" +kubectl yconverge --context=local -k k3s/62-buildkit/ + +echo "" +echo "# Phase 4: prod registry" +kubectl yconverge --context=local -k k3s/61-prod-registry/ + +echo "" +echo "# Phase 5: monitoring (independent branch)" +kubectl yconverge --context=local -k k3s/50-monitoring/ + +echo "" +echo "# Phase 6: idempotency proof — re-converge everything" +kubectl yconverge --context=local -k k3s/62-buildkit/ +kubectl yconverge --context=local -k k3s/50-monitoring/ +kubectl yconverge --context=local -k k3s/61-prod-registry/ +kubectl yconverge --context=local -k k3s/40-kafka/ + +echo "" +echo "# Phase 7: validate the complete stack" y-cluster-validate-ystack --context=local echo "Acceptance tests completed" diff --git a/e2e/agents-clusterautomation-acceptance-osx-arm64.sh b/e2e/agents-clusterautomation-acceptance-osx-arm64.sh index 3491ab92..f7b99a88 100755 --- a/e2e/agents-clusterautomation-acceptance-osx-arm64.sh +++ b/e2e/agents-clusterautomation-acceptance-osx-arm64.sh @@ -42,7 +42,40 @@ trap cleanup EXIT cleanup lsof -iTCP:80 -iTCP:443 -sTCP:LISTEN -P -n >/dev/null 2>&1 && echo "port 80 and 443 must be available for local cluster vm to bind to" && exit 1 -y-cluster-provision-k3d + +y-cluster-provision --skip-converge + +# --- progressive convergence: proves DAG resolves deps without include/exclude --- + +echo "" +echo "# Phase 1: base platform (registry + y-kustomize serving)" +kubectl yconverge --context=local -k k3s/60-builds-registry/ + +echo "" +echo "# Phase 2: kafka stack (transitive deps through y-kustomize)" +kubectl yconverge --context=local -k k3s/40-kafka/ + +echo "" +echo "# Phase 3: build infra" +kubectl yconverge --context=local -k k3s/62-buildkit/ + +echo "" +echo "# Phase 4: prod registry" +kubectl yconverge --context=local -k k3s/61-prod-registry/ + +echo "" +echo "# Phase 5: monitoring (independent branch)" +kubectl yconverge --context=local -k k3s/50-monitoring/ + +echo "" +echo "# Phase 6: idempotency proof — re-converge everything" +kubectl yconverge --context=local -k k3s/62-buildkit/ +kubectl yconverge --context=local -k k3s/50-monitoring/ +kubectl yconverge --context=local -k k3s/61-prod-registry/ +kubectl yconverge --context=local -k k3s/40-kafka/ + +echo "" +echo "# Phase 7: validate the complete stack" y-cluster-validate-ystack --context=local echo "Acceptance tests completed" 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/y-kustomize.blobs.setup-bucket-job.yaml b/k3s/09-y-kustomize-secrets-init/y-kustomize.blobs.setup-bucket-job.yaml deleted file mode 100644 index 364012e9..00000000 --- a/k3s/09-y-kustomize-secrets-init/y-kustomize.blobs.setup-bucket-job.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: y-kustomize.blobs.setup-bucket-job -type: Opaque diff --git a/k3s/09-y-kustomize-secrets-init/y-kustomize.kafka.setup-topic-job.yaml b/k3s/09-y-kustomize-secrets-init/y-kustomize.kafka.setup-topic-job.yaml deleted file mode 100644 index 66ab2c42..00000000 --- a/k3s/09-y-kustomize-secrets-init/y-kustomize.kafka.setup-topic-job.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: y-kustomize.kafka.setup-topic-job -type: Opaque 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..c3dc211e --- /dev/null +++ b/k3s/20-gateway/yconverge.cue @@ -0,0 +1,28 @@ +package gateway + +import "yolean.se/ystack/yconverge/verify" + +// Gateway API CRDs are assumed installed by the provisioner. +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 -write || 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..3fe66dd6 --- /dev/null +++ b/k3s/29-y-kustomize/yconverge.cue @@ -0,0 +1,23 @@ +package y_kustomize + +import "yolean.se/ystack/yconverge/verify" + +// No dependencies — y-kustomize watches secrets via API, doesn't +// need them pre-created. Gateway API is assumed by provisioner. + +step: verify.#Step & { + checks: [ + { + kind: "rollout" + resource: "deploy/y-kustomize" + namespace: "ystack" + timeout: "120s" + }, + { + kind: "exec" + command: "y-k8s-ingress-hosts --context=$CONTEXT -write || echo 'WARNING: /etc/hosts update failed (may need manual sudo)'" + timeout: "10s" + description: "update /etc/hosts for y-kustomize HTTPRoute" + }, + ] +} 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..a7ca3a25 --- /dev/null +++ b/k3s/30-blobs-ystack/yconverge.cue @@ -0,0 +1,20 @@ +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 & { + // y-kustomize watches secrets via API — no restart needed. + checks: [{ + 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: "30s" + description: "y-kustomize serving blobs bases" + }] +} 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..a38d1b8d --- /dev/null +++ b/k3s/40-kafka-ystack/yconverge.cue @@ -0,0 +1,28 @@ +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 & { + // y-kustomize watches secrets via API — no restart needed. + checks: [ + { + 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: "30s" + description: "y-kustomize serving kafka bases" + }, + { + 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: "30s" + description: "y-kustomize serving blobs bases" + }, + ] +} 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/kafka/y-kustomize/kustomization.yaml b/kafka/y-kustomize/kustomization.yaml index 36b5dd24..9ad696fd 100644 --- a/kafka/y-kustomize/kustomization.yaml +++ b/kafka/y-kustomize/kustomization.yaml @@ -8,6 +8,6 @@ secretGenerator: options: disableNameSuffixHash: true labels: - yolean.se/module-part: config + yolean.se/module-part: y-kustomize files: - base-for-annotations.yaml=y-kustomize-bases/kafka/setup-topic-job/setup-topic-job.yaml diff --git a/monitoring/TODO.md b/monitoring/TODO.md new file mode 100644 index 00000000..15225f90 --- /dev/null +++ b/monitoring/TODO.md @@ -0,0 +1,25 @@ +# Monitoring infrastructure setup TODO + +Tracks remaining work to fully converge the monitoring stack on vanilla Prometheus v3. +Ref: PR #67 review comments. + +## Converge prerequisite for e2e + +The `httproute prometheus-now` validation check requires the full converge sequence. +Run `y-cluster-converge-ystack --context=local` (or the relevant context) to apply all +steps including `09-prometheus-httproute`. The validate script only asserts state — it +does not create resources. + +## Remaining tasks + +- [ ] Drop `monitoring/prometheus-operator/` once all clusters run vanilla Prometheus +- [ ] Drop `monitoring/kube-state-metrics/` (operator CRD variant) in favor of `kube-state-metrics-now/` +- [ ] Drop `monitoring/node-exporter/node-exporter-podmonitor.yaml` — the PodMonitor CRD + is only used by the operator; vanilla Prometheus discovers via the `metrics` port convention +- [ ] Update `k3s/30-monitoring-operator/` — either remove or gate behind a feature flag +- [ ] Migrate `monitoring/grafana/grafana-service.yaml` annotations (`prometheus.io/scrape`) + to also expose a port named `metrics` for consistency with the pod SD convention +- [ ] Fix `k3s/09-prometheus-httproute/kustomization.yaml` — uses deprecated `bases:` key, + should be `resources:` +- [ ] Add persistent volume for Prometheus data (currently `emptyDir {}`) +- [ ] Wire up Alertmanager to the converge and validate scripts 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/y-kustomize/cmd/.gitignore b/y-kustomize/cmd/.gitignore new file mode 100644 index 00000000..854b19d7 --- /dev/null +++ b/y-kustomize/cmd/.gitignore @@ -0,0 +1,3 @@ +y-kustomize +target/ +target-oci/ diff --git a/y-kustomize/cmd/contain.yaml b/y-kustomize/cmd/contain.yaml new file mode 100644 index 00000000..aa1edf93 --- /dev/null +++ b/y-kustomize/cmd/contain.yaml @@ -0,0 +1,12 @@ +# yaml-language-server: $schema=https://github.com/turbokube/contain/raw/refs/heads/main/jsonschema/config.json +base: gcr.io/distroless/static:nonroot@sha256:e3f945647ffb95b5839c07038d64f9811adf17308b9121d8a2b87b6a22a80a39 +layers: +- localFile: + path: target/linux/amd64/y-kustomize + containerPath: /usr/local/bin/y-kustomize + layerAttributes: + uid: 65532 + gid: 65534 + mode: 0755 +entrypoint: +- /usr/local/bin/y-kustomize diff --git a/y-kustomize/cmd/go.mod b/y-kustomize/cmd/go.mod new file mode 100644 index 00000000..daee3761 --- /dev/null +++ b/y-kustomize/cmd/go.mod @@ -0,0 +1,47 @@ +module yolean.se/ystack/y-kustomize + +go 1.26.1 + +require ( + k8s.io/apimachinery v0.35.4 + k8s.io/client-go v0.35.4 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.35.4 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/y-kustomize/cmd/go.sum b/y-kustomize/cmd/go.sum new file mode 100644 index 00000000..a819cb23 --- /dev/null +++ b/y-kustomize/cmd/go.sum @@ -0,0 +1,129 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.4 h1:P7nFYKl5vo9AGUp1Z+Pmd3p2tA7bX2wbFWCvDeRv988= +k8s.io/api v0.35.4/go.mod h1:yl4lqySWOgYJJf9RERXKUwE9g2y+CkuwG+xmcOK8wXU= +k8s.io/apimachinery v0.35.4 h1:xtdom9RG7e+yDp71uoXoJDWEE2eOiHgeO4GdBzwWpds= +k8s.io/apimachinery v0.35.4/go.mod h1:NNi1taPOpep0jOj+oRha3mBJPqvi0hGdaV8TCqGQ+cc= +k8s.io/client-go v0.35.4 h1:DN6fyaGuzK64UvnKO5fOA6ymSjvfGAnCAHAR0C66kD8= +k8s.io/client-go v0.35.4/go.mod h1:2Pg9WpsS4NeOpoYTfHHfMxBG8zFMSAUi4O/qoiJC3nY= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/y-kustomize/cmd/main.go b/y-kustomize/cmd/main.go new file mode 100644 index 00000000..cc8c09e7 --- /dev/null +++ b/y-kustomize/cmd/main.go @@ -0,0 +1,182 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" +) + +const ( + labelSelector = "yolean.se/module-part=y-kustomize" + // Secret name convention: y-kustomize.{group}.{name} + // Served at: /v1/{group}/{name}/{key} + secretPrefix = "y-kustomize." +) + +type server struct { + mu sync.RWMutex + // path -> content + files map[string][]byte + client kubernetes.Interface + ns string +} + +func (s *server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/health" { + w.WriteHeader(http.StatusOK) + return + } + + s.mu.RLock() + content, ok := s.files[r.URL.Path] + s.mu.RUnlock() + + if !ok { + http.NotFound(w, r) + return + } + + w.Header().Set("Content-Type", "application/x-yaml") + w.Write(content) +} + +// secretToFiles converts a secret's data keys to URL paths. +// Secret name y-kustomize.blobs.setup-bucket-job with key base-for-annotations.yaml +// becomes /v1/blobs/setup-bucket-job/base-for-annotations.yaml +func secretToFiles(name string, data map[string][]byte) map[string][]byte { + if !strings.HasPrefix(name, secretPrefix) { + return nil + } + suffix := strings.TrimPrefix(name, secretPrefix) + // suffix = "blobs.setup-bucket-job" -> path = "blobs/setup-bucket-job" + pathBase := "/v1/" + strings.Replace(suffix, ".", "/", 1) + + files := make(map[string][]byte) + for key, val := range data { + files[pathBase+"/"+key] = val + } + return files +} + +func (s *server) syncAll(ctx context.Context) error { + secrets, err := s.client.CoreV1().Secrets(s.ns).List(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if err != nil { + return fmt.Errorf("list secrets: %w", err) + } + + files := make(map[string][]byte) + for _, sec := range secrets.Items { + for path, content := range secretToFiles(sec.Name, sec.Data) { + files[path] = content + log.Printf("serving %s (%d bytes)", path, len(content)) + } + } + + s.mu.Lock() + s.files = files + s.mu.Unlock() + return nil +} + +func (s *server) watchSecrets(ctx context.Context) { + for { + log.Printf("starting secret watch (label=%s, ns=%s)", labelSelector, s.ns) + watcher, err := s.client.CoreV1().Secrets(s.ns).Watch(ctx, metav1.ListOptions{ + LabelSelector: labelSelector, + }) + if err != nil { + log.Printf("watch error: %v, retrying in 5s", err) + select { + case <-ctx.Done(): + return + default: + sleepCtx(ctx, 5*time.Second) + } + continue + } + + for event := range watcher.ResultChan() { + switch event.Type { + case watch.Added, watch.Modified: + if err := s.syncAll(ctx); err != nil { + log.Printf("sync error on %s: %v", event.Type, err) + } + case watch.Deleted: + if err := s.syncAll(ctx); err != nil { + log.Printf("sync error on delete: %v", err) + } + case watch.Error: + log.Printf("watch error event, restarting watch") + } + } + log.Printf("watch channel closed, restarting") + } +} + +func sleepCtx(ctx context.Context, d time.Duration) { + select { + case <-ctx.Done(): + case <-time.After(d): + } +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8787" + } + + ns := os.Getenv("NAMESPACE") + if ns == "" { + // Try in-cluster namespace + data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace") + if err == nil { + ns = strings.TrimSpace(string(data)) + } else { + ns = "ystack" + } + } + + config, err := rest.InClusterConfig() + if err != nil { + log.Fatalf("in-cluster config: %v", err) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + log.Fatalf("kubernetes client: %v", err) + } + + s := &server{ + files: make(map[string][]byte), + client: clientset, + ns: ns, + } + + ctx := context.Background() + + // Initial sync + if err := s.syncAll(ctx); err != nil { + log.Printf("initial sync: %v (will retry via watch)", err) + } + + // Start watching for changes + go s.watchSecrets(ctx) + + log.Printf("y-kustomize listening on :%s (ns=%s, label=%s)", port, ns, labelSelector) + if err := http.ListenAndServe(":"+port, s); err != nil { + log.Fatal(err) + } +} diff --git a/y-kustomize/cmd/main_test.go b/y-kustomize/cmd/main_test.go new file mode 100644 index 00000000..0f6438fe --- /dev/null +++ b/y-kustomize/cmd/main_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "testing" +) + +func TestSecretToFiles(t *testing.T) { + tests := []struct { + name string + data map[string][]byte + want map[string][]byte + }{ + { + name: "y-kustomize.blobs.setup-bucket-job", + data: map[string][]byte{ + "base-for-annotations.yaml": []byte("apiVersion: v1\nkind: Secret"), + }, + want: map[string][]byte{ + "/v1/blobs/setup-bucket-job/base-for-annotations.yaml": []byte("apiVersion: v1\nkind: Secret"), + }, + }, + { + name: "y-kustomize.kafka.setup-topic-job", + data: map[string][]byte{ + "base-for-annotations.yaml": []byte("apiVersion: batch/v1\nkind: Job"), + }, + want: map[string][]byte{ + "/v1/kafka/setup-topic-job/base-for-annotations.yaml": []byte("apiVersion: batch/v1\nkind: Job"), + }, + }, + { + name: "unrelated-secret", + data: map[string][]byte{"key": []byte("value")}, + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := secretToFiles(tt.name, tt.data) + if tt.want == nil { + if got != nil { + t.Errorf("expected nil, got %v", got) + } + return + } + for path, content := range tt.want { + if string(got[path]) != string(content) { + t.Errorf("path %s: got %q, want %q", path, got[path], content) + } + } + }) + } +} diff --git a/y-kustomize/cmd/skaffold.yaml b/y-kustomize/cmd/skaffold.yaml new file mode 100644 index 00000000..50a85ec8 --- /dev/null +++ b/y-kustomize/cmd/skaffold.yaml @@ -0,0 +1,33 @@ +apiVersion: skaffold/v4beta6 +kind: Config +metadata: + name: y-kustomize +build: + tagPolicy: + gitCommit: + variant: CommitSha + artifacts: + - image: ghcr.io/yolean/y-kustomize + context: . + custom: + buildCommand: | + set -e + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o target/linux/amd64/y-kustomize . && + PLATFORMS=linux/amd64 IMAGE=$IMAGE y-contain build --push=false --tarball target-oci/y-kustomize.tar --platforms-env-require && + cat target-oci/y-kustomize.tar | y-cluster-local-ctr -n k8s.io images import --digests - + dependencies: + paths: + - "**/*.go" + - contain.yaml + - go.mod + - go.sum + local: + push: false + useBuildkit: false +deploy: + kubectl: + defaultNamespace: ystack + hooks: + after: + - host: + command: ["sh", "-c", "kubectl --context=local -n ystack rollout restart deploy/y-kustomize"] diff --git a/y-kustomize/deployment.yaml b/y-kustomize/deployment.yaml index cfab2dc3..43633fb0 100644 --- a/y-kustomize/deployment.yaml +++ b/y-kustomize/deployment.yaml @@ -15,18 +15,10 @@ spec: labels: app: y-kustomize spec: + serviceAccountName: y-kustomize containers: - - name: sws - image: ghcr.io/yolean/static-web-server:2.41.0 - args: - - --port=8787 - - --root=/srv - - --directory-listing=false - - --health - - --log-level=info - - --log-remote-address - - --ignore-hidden-files=false - - --disable-symlinks=false + - name: y-kustomize + image: ghcr.io/yolean/y-kustomize:c55953b69f74067043f2351f8727ea84db1737ca@sha256:e44f99f6bbae59aef485610402c8f3f0125e197fff8616643bd4d5c65ce619e1 ports: - containerPort: 8787 name: http @@ -37,18 +29,6 @@ spec: resources: requests: cpu: 5m - memory: 8Mi + memory: 16Mi limits: memory: 32Mi - volumeMounts: - - name: base-blobs-setup-bucket-job - mountPath: /srv/v1/blobs/setup-bucket-job - - name: base-kafka-setup-topic-job - mountPath: /srv/v1/kafka/setup-topic-job - volumes: - - name: base-blobs-setup-bucket-job - secret: - secretName: y-kustomize.blobs.setup-bucket-job - - name: base-kafka-setup-topic-job - secret: - secretName: y-kustomize.kafka.setup-topic-job diff --git a/y-kustomize/kustomization.yaml b/y-kustomize/kustomization.yaml index f029df14..8468524a 100644 --- a/y-kustomize/kustomization.yaml +++ b/y-kustomize/kustomization.yaml @@ -3,6 +3,7 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization namespace: ystack resources: +- rbac.yaml - deployment.yaml - service.yaml - httproute.yaml diff --git a/y-kustomize/rbac.yaml b/y-kustomize/rbac.yaml new file mode 100644 index 00000000..a0352e01 --- /dev/null +++ b/y-kustomize/rbac.yaml @@ -0,0 +1,25 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: y-kustomize +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: y-kustomize +rules: +- apiGroups: [""] + resources: ["secrets"] + verbs: ["list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: y-kustomize +subjects: +- kind: ServiceAccount + name: y-kustomize +roleRef: + kind: Role + name: y-kustomize + apiGroup: rbac.authorization.k8s.io diff --git a/k3s/09-y-kustomize-secrets-init/kustomization.yaml b/yconverge/itest/cluster-prod/db/kustomization.yaml similarity index 60% rename from k3s/09-y-kustomize-secrets-init/kustomization.yaml rename to yconverge/itest/cluster-prod/db/kustomization.yaml index 74657401..575a1403 100644 --- a/k3s/09-y-kustomize-secrets-init/kustomization.yaml +++ b/yconverge/itest/cluster-prod/db/kustomization.yaml @@ -1,7 +1,9 @@ # yaml-language-server: $schema=https://json.schemastore.org/kustomization.json apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -namespace: ystack + +namespace: db + resources: -- y-kustomize.blobs.setup-bucket-job.yaml -- y-kustomize.kafka.setup-topic-job.yaml +- ../../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..ae82e53b --- /dev/null +++ b/yconverge/itest/test.sh @@ -0,0 +1,262 @@ +#!/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" + +# --- lint (zero failures required) --- + +echo "[cue itest] Linting scripts ..." +y-script-lint "$YSTACK_HOME/bin/y-cluster-converge-ystack" +y-script-lint "$YSTACK_HOME/bin/y-image-list-ystack" +y-script-lint "$YSTACK_HOME/bin/kubectl-yconverge" + +# --- 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/ + +# --- dependency ordering: checks must complete before downstream steps start --- + +echo "" +echo "[cue itest] Verify dependency checks serialize before downstream steps" +_DEP_OUT=$(mktemp /tmp/yconverge-itest-deps.XXXXXX) +kubectl-yconverge --context="$CTX" -k yconverge/itest/example-with-dependency/ 2>&1 | tee "$_DEP_OUT" +# namespace check must complete before configmap step begins +_ns_check=$(grep -n 'condition met' "$_DEP_OUT" | head -1 | cut -d: -f1) +_cm_step=$(grep -n '>>> .*example-configmap' "$_DEP_OUT" | cut -d: -f1) +[ "$_ns_check" -lt "$_cm_step" ] \ + || { echo "[cue itest] FAIL: namespace check (line $_ns_check) must complete before configmap step (line $_cm_step)"; exit 1; } +# configmap check must complete before with-dependency step begins +_cm_check=$(grep -n 'configmap exists' "$_DEP_OUT" | head -1 | cut -d: -f1) +_wd_step=$(grep -n '>>> .*example-with-dependency' "$_DEP_OUT" | cut -d: -f1) +[ "$_cm_check" -lt "$_wd_step" ] \ + || { echo "[cue itest] FAIL: configmap check (line $_cm_check) must complete before with-dependency step (line $_wd_step)"; exit 1; } +rm -f "$_DEP_OUT" + +# --- 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..20055449 --- /dev/null +++ b/yconverge/verify/schema.cue @@ -0,0 +1,44 @@ +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] +} + +// 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 +}