Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
90e8923
kubectl yconverge: declarative checks/waits and new label support
solsson Apr 16, 2026
1dc9091
yconverge: remove dead schema fields, add dep-ordering test
solsson Apr 16, 2026
595110f
Drafts e2e scripts for next step, happy paths
solsson Apr 16, 2026
d2382e0
Provisioner always sets up Gateway API, remove from functional DAG
Apr 16, 2026
4358e02
Add /etc/hosts update to y-kustomize step (after HTTPRoute exists)
Apr 16, 2026
cbad3f2
Use kustomize-identical URLs for y-kustomize content checks
Apr 16, 2026
d40ad8c
Fix /etc/hosts clearing: guard against empty write, reduce timeouts
Apr 16, 2026
c48ddbd
Replace static-web-server with purpose-built Go y-kustomize
Apr 20, 2026
5a94b03
Add contain v0.8.0 to y-bin, local build for y-kustomize image
Apr 20, 2026
7558190
Fix init secrets to use create-mode, add qemu to y-cluster-local-ctr
Apr 20, 2026
e32c211
Remove 09-y-kustomize-secrets-init
Apr 20, 2026
81c49c8
CI: build y-kustomize image on push, temporarily include branch
Apr 20, 2026
c384a91
Merge WIP branch 'y-kustomize-backend-image' into y-converge-checks-dag
solsson Apr 20, 2026
2c4509f
Pin y-kustomize to published CI image
Apr 20, 2026
540c187
skaffold.yaml for y-kustomize dev loop with contain + ctr import
Apr 20, 2026
3a56e97
Restore clean env in e2e, increase registry rollout timeout
Apr 20, 2026
cbd3005
Revert timeout change, restore clean env
Apr 20, 2026
c2f6eae
Fix provisioner gateway setup: cd to YSTACK_HOME for relative paths
Apr 20, 2026
ccba536
Fix kubeconfig null lists after teardown (kubie compatibility)
Apr 20, 2026
9ca81db
Add --converge flag with image caching passthrough
Apr 21, 2026
93e4103
Upgrade k3s to v1.35.3, use ClusterIPs for registry mirrors
Apr 21, 2026
b9977a7
Add script lint to itest
Apr 22, 2026
87e119b
Export NAMESPACE to check commands
Apr 22, 2026
fe5e0f8
Add kustomize-traverse v0.1.0, replace CUE lookup heuristic
Apr 22, 2026
2bb75d6
Address PR review: error handling, DX, cleanup
Apr 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/lint.yaml → .github/workflows/checks.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: lint
name: checks

on:
push:
Expand Down Expand Up @@ -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
359 changes: 359 additions & 0 deletions bin/kubectl-yconverge
Original file line number Diff line number Diff line change
@@ -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=<name> [flags] -k <kustomize-dir>
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
Loading
Loading