From 8eb2806f80943b24d5cb52a47658b6ebf5e8ff95 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Wed, 22 Apr 2026 04:37:10 +0000 Subject: [PATCH 01/43] feat: initial kustomize-traverse implementation --- SPEC.md | 168 ---------------------------------------------------- e2e_test.go | 142 -------------------------------------------- 2 files changed, 310 deletions(-) delete mode 100644 SPEC.md delete mode 100644 e2e_test.go diff --git a/SPEC.md b/SPEC.md deleted file mode 100644 index 8752226..0000000 --- a/SPEC.md +++ /dev/null @@ -1,168 +0,0 @@ -# kustomize-traverse - -Walk a kustomization directory tree and report structural metadata -that `kustomize build` does not expose: the set of local directories -visited and the namespace resolution at each level. - -## Motivation - -`kubectl-yconverge` needs to find `yconverge.cue` check files that -live next to `kustomization.yaml` in base directories. It also needs -the resolved namespace for each target so check commands can reference -`$NAMESPACE`. Today this is approximated with bash heuristics that -break when a kustomization has multiple local directory resources. - -Using `sigs.k8s.io/kustomize/api/types` to parse `kustomization.yaml` -gives us the same directory and namespace resolution that kustomize -itself uses, without rendering resources. - -## Usage - -``` -kustomize-traverse [flags] -``` - -`` is a directory containing `kustomization.yaml`. - -## Flags - -``` - -o, --output FORMAT Output format (default: dirs) - dirs One local directory per line, depth-first - namespace Print only the resolved namespace - json JSON object with dirs and namespace - -q, --quiet Suppress warnings (e.g. unresolvable remote refs) -``` - -## Output formats - -### `-o dirs` (default) - -One line per local directory visited during traversal, depth-first -order (bases before the referencing overlay). Includes only directories -that contain a `kustomization.yaml`. Remote refs (github URLs, HTTP) -are skipped silently. - -``` -$ kustomize-traverse -o dirs gateway-v4/site-apply-namespaced/ -../../site-chart-v1/generated/modules/settings-sitevalues -../../site-chart-v1/generated/modules/settings-auth -../site-apply -. -``` - -Paths are relative to ``. The final `.` is the target itself. - -Shell consumption: - -```bash -for dir in $(kustomize-traverse -o dirs "$KUSTOMIZE_DIR"); do - abs="$KUSTOMIZE_DIR/$dir" - [ -f "$abs/yconverge.cue" ] && echo "$abs" -done -``` - -### `-o namespace` - -Print only the resolved namespace and exit. Resolution follows -kustomize semantics: - -1. The outermost `kustomization.yaml` `namespace:` field wins -2. If unset, walk into the single resource base (if exactly one) -3. If still unset, print nothing (exit 0, empty output) - -``` -$ kustomize-traverse -o namespace gateway-v4/site-apply-namespaced/ -dev -``` - -Shell consumption: - -```bash -NAMESPACE=$(kustomize-traverse -o namespace "$KUSTOMIZE_DIR") -``` - -### `-o json` - -Single JSON object combining both: - -```json -{ - "namespace": "dev", - "dirs": [ - "../../site-chart-v1/generated/modules/settings-sitevalues", - "../../site-chart-v1/generated/modules/settings-auth", - "../site-apply", - "." - ] -} -``` - -Shell consumption via jq, or direct use from Go callers. - -## Traversal rules - -1. Parse `kustomization.yaml` (or `kustomization.yml`, `Kustomization`) - using `sigs.k8s.io/kustomize/api/types`. -2. Collect `resources` and `components` entries. -3. For each entry that resolves to a local directory containing a - kustomization file, recurse (depth-first). -4. Skip remote refs (HTTP URLs, `github.com/...`), file refs - (entries that resolve to files, not directories), and entries - whose target directory does not exist. -5. Emit each visited directory exactly once (deduplicate by - resolved absolute path). -6. The target directory itself is always the last entry. - -## Namespace resolution - -Read the `namespace:` field from the outermost kustomization. -This matches kustomize behavior: the outermost overlay's namespace -overrides all bases. - -If the outermost has no `namespace:` field, fall back to the first -base directory that has one (depth-first). This covers the indirection -case where `site-apply-namespaced/` (generated, may lack namespace) -references `../site-apply/` (which declares namespace). - -This is not a full reimplementation of kustomize namespace -transformation — it's a static read of the `namespace:` field from -kustomization files, which is sufficient for yconverge's needs. - -## Exit codes - -- 0: success -- 1: `` does not contain a kustomization file -- 2: flag parse error - -Unresolvable remote refs or missing directories are not errors — -they are skipped (with a warning unless `-q`). - -## Build - -``` -go build -o kustomize-traverse . -``` - -Single dependency: `sigs.k8s.io/kustomize/api` for the types. -No need for the full krusty engine — only `types.Kustomization` -unmarshaling and local filesystem access. - -## Integration with kubectl-yconverge - -Replace `_find_cue_dir()` in `kubectl-yconverge` with: - -```bash -_find_cue_dirs() { - kustomize-traverse -o dirs "$1" | while read -r rel; do - abs="$1/$rel" - [ -f "$abs/yconverge.cue" ] && echo "$abs" - done -} - -NAMESPACE=$(kustomize-traverse -o namespace "$KUSTOMIZE_DIR") -export NAMESPACE -``` - -This replaces both the namespace guessing logic and the -single-level CUE file lookup with kustomize-native resolution. diff --git a/e2e_test.go b/e2e_test.go deleted file mode 100644 index 3ca0201..0000000 --- a/e2e_test.go +++ /dev/null @@ -1,142 +0,0 @@ -package main - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -var binPath string - -func TestMain(m *testing.M) { - dir, err := os.MkdirTemp("", "kustomize-traverse-bin-") - if err != nil { - panic(err) - } - defer os.RemoveAll(dir) - binPath = filepath.Join(dir, "kustomize-traverse") - cmd := exec.Command("go", "build", "-o", binPath, ".") - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - panic("go build failed: " + err.Error()) - } - os.Exit(m.Run()) -} - -func runBin(t *testing.T, args ...string) (stdout, stderr string, code int) { - t.Helper() - cmd := exec.Command(binPath, args...) - var outBuf, errBuf bytes.Buffer - cmd.Stdout = &outBuf - cmd.Stderr = &errBuf - err := cmd.Run() - code = 0 - if err != nil { - if ee, ok := err.(*exec.ExitError); ok { - code = ee.ExitCode() - } else { - t.Fatalf("exec: %v", err) - } - } - return outBuf.String(), errBuf.String(), code -} - -func TestE2EDirsAndNamespace(t *testing.T) { - root := t.TempDir() - writeFiles(t, root, map[string]string{ - "leaf/kustomization.yaml": "namespace: dev\n", - "mid/kustomization.yaml": "resources:\n- ../leaf\n- extra.yaml\n- github.com/owner/repo//p?ref=v1\n", - "mid/extra.yaml": "kind: ConfigMap\n", - "overlay/kustomization.yaml": "resources:\n- ../mid\n", - }) - - stdout, stderr, code := runBin(t, "-o", "dirs", filepath.Join(root, "overlay")) - if code != 0 { - t.Fatalf("dirs: exit=%d stderr=%s", code, stderr) - } - lines := strings.Split(strings.TrimRight(stdout, "\n"), "\n") - want := []string{"../leaf", "../mid", "."} - if !equalStrings(lines, want) { - t.Fatalf("dirs: got %v want %v", lines, want) - } - - stdout, _, code = runBin(t, "-o", "namespace", filepath.Join(root, "overlay")) - if code != 0 || strings.TrimSpace(stdout) != "dev" { - t.Fatalf("namespace: stdout=%q code=%d", stdout, code) - } - - stdout, _, code = runBin(t, "--output", "json", filepath.Join(root, "overlay")) - if code != 0 { - t.Fatalf("json: code=%d", code) - } - if !strings.Contains(stdout, `"namespace": "dev"`) { - t.Fatalf("json missing namespace: %s", stdout) - } - if !strings.Contains(stdout, `"../leaf"`) || !strings.Contains(stdout, `"../mid"`) { - t.Fatalf("json missing dirs: %s", stdout) - } -} - -func TestE2EExitCodes(t *testing.T) { - // exit 1: no kustomization file at - empty := t.TempDir() - _, stderr, code := runBin(t, empty) - if code != 1 { - t.Fatalf("missing kustomization: expected 1, got %d (%s)", code, stderr) - } - if !strings.Contains(stderr, "no kustomization") { - t.Fatalf("expected diagnostic, got %q", stderr) - } - - // exit 2: missing path - _, _, code = runBin(t) - if code != 2 { - t.Fatalf("missing arg: expected 2, got %d", code) - } - - // exit 2: unknown flag - _, _, code = runBin(t, "--nope", ".") - if code != 2 { - t.Fatalf("unknown flag: expected 2, got %d", code) - } - - // exit 2: unknown output format - root := t.TempDir() - writeFiles(t, root, map[string]string{"k/kustomization.yaml": ""}) - _, _, code = runBin(t, "-o", "xml", filepath.Join(root, "k")) - if code != 2 { - t.Fatalf("unknown -o: expected 2, got %d", code) - } -} - -func TestE2EWarningsStreamToStderrOnly(t *testing.T) { - root := t.TempDir() - writeFiles(t, root, map[string]string{ - "k/kustomization.yaml": "resources:\n- ../missing\n", - }) - stdout, stderr, code := runBin(t, "-o", "dirs", filepath.Join(root, "k")) - if code != 0 { - t.Fatalf("exit=%d", code) - } - if strings.TrimSpace(stdout) != "." { - t.Fatalf("stdout should be '.', got %q", stdout) - } - if !strings.Contains(stderr, "warning") { - t.Fatalf("expected warning on stderr, got %q", stderr) - } - - // -q silences it, stdout unchanged - stdout, stderr, code = runBin(t, "-q", "-o", "dirs", filepath.Join(root, "k")) - if code != 0 { - t.Fatalf("exit=%d", code) - } - if strings.TrimSpace(stdout) != "." { - t.Fatalf("quiet stdout got %q", stdout) - } - if strings.Contains(stderr, "warning") { - t.Fatalf("unexpected warning with -q: %q", stderr) - } -} From 149a62916aea7ae52ea074679c122cb3ac3cf15c Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Wed, 22 Apr 2026 05:07:01 +0000 Subject: [PATCH 02/43] test: add e2e tests that exec the built binary --- e2e_test.go | 142 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 e2e_test.go diff --git a/e2e_test.go b/e2e_test.go new file mode 100644 index 0000000..3ca0201 --- /dev/null +++ b/e2e_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +var binPath string + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "kustomize-traverse-bin-") + if err != nil { + panic(err) + } + defer os.RemoveAll(dir) + binPath = filepath.Join(dir, "kustomize-traverse") + cmd := exec.Command("go", "build", "-o", binPath, ".") + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + panic("go build failed: " + err.Error()) + } + os.Exit(m.Run()) +} + +func runBin(t *testing.T, args ...string) (stdout, stderr string, code int) { + t.Helper() + cmd := exec.Command(binPath, args...) + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + err := cmd.Run() + code = 0 + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } else { + t.Fatalf("exec: %v", err) + } + } + return outBuf.String(), errBuf.String(), code +} + +func TestE2EDirsAndNamespace(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "leaf/kustomization.yaml": "namespace: dev\n", + "mid/kustomization.yaml": "resources:\n- ../leaf\n- extra.yaml\n- github.com/owner/repo//p?ref=v1\n", + "mid/extra.yaml": "kind: ConfigMap\n", + "overlay/kustomization.yaml": "resources:\n- ../mid\n", + }) + + stdout, stderr, code := runBin(t, "-o", "dirs", filepath.Join(root, "overlay")) + if code != 0 { + t.Fatalf("dirs: exit=%d stderr=%s", code, stderr) + } + lines := strings.Split(strings.TrimRight(stdout, "\n"), "\n") + want := []string{"../leaf", "../mid", "."} + if !equalStrings(lines, want) { + t.Fatalf("dirs: got %v want %v", lines, want) + } + + stdout, _, code = runBin(t, "-o", "namespace", filepath.Join(root, "overlay")) + if code != 0 || strings.TrimSpace(stdout) != "dev" { + t.Fatalf("namespace: stdout=%q code=%d", stdout, code) + } + + stdout, _, code = runBin(t, "--output", "json", filepath.Join(root, "overlay")) + if code != 0 { + t.Fatalf("json: code=%d", code) + } + if !strings.Contains(stdout, `"namespace": "dev"`) { + t.Fatalf("json missing namespace: %s", stdout) + } + if !strings.Contains(stdout, `"../leaf"`) || !strings.Contains(stdout, `"../mid"`) { + t.Fatalf("json missing dirs: %s", stdout) + } +} + +func TestE2EExitCodes(t *testing.T) { + // exit 1: no kustomization file at + empty := t.TempDir() + _, stderr, code := runBin(t, empty) + if code != 1 { + t.Fatalf("missing kustomization: expected 1, got %d (%s)", code, stderr) + } + if !strings.Contains(stderr, "no kustomization") { + t.Fatalf("expected diagnostic, got %q", stderr) + } + + // exit 2: missing path + _, _, code = runBin(t) + if code != 2 { + t.Fatalf("missing arg: expected 2, got %d", code) + } + + // exit 2: unknown flag + _, _, code = runBin(t, "--nope", ".") + if code != 2 { + t.Fatalf("unknown flag: expected 2, got %d", code) + } + + // exit 2: unknown output format + root := t.TempDir() + writeFiles(t, root, map[string]string{"k/kustomization.yaml": ""}) + _, _, code = runBin(t, "-o", "xml", filepath.Join(root, "k")) + if code != 2 { + t.Fatalf("unknown -o: expected 2, got %d", code) + } +} + +func TestE2EWarningsStreamToStderrOnly(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "k/kustomization.yaml": "resources:\n- ../missing\n", + }) + stdout, stderr, code := runBin(t, "-o", "dirs", filepath.Join(root, "k")) + if code != 0 { + t.Fatalf("exit=%d", code) + } + if strings.TrimSpace(stdout) != "." { + t.Fatalf("stdout should be '.', got %q", stdout) + } + if !strings.Contains(stderr, "warning") { + t.Fatalf("expected warning on stderr, got %q", stderr) + } + + // -q silences it, stdout unchanged + stdout, stderr, code = runBin(t, "-q", "-o", "dirs", filepath.Join(root, "k")) + if code != 0 { + t.Fatalf("exit=%d", code) + } + if strings.TrimSpace(stdout) != "." { + t.Fatalf("quiet stdout got %q", stdout) + } + if strings.Contains(stderr, "warning") { + t.Fatalf("unexpected warning with -q: %q", stderr) + } +} From 44abb9b527504ed7ae2fbbe9c8124904867a6caa Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Thu, 23 Apr 2026 06:39:03 +0000 Subject: [PATCH 03/43] docs: add SPEC.md and CI.md From 2cd732eaff4d5da276b12847d49f4dac1b7fd09a Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Thu, 23 Apr 2026 15:02:55 +0000 Subject: [PATCH 04/43] docs: add README.md, refine SPEC.md and CI.md --- README.md | 241 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..e97364e --- /dev/null +++ b/README.md @@ -0,0 +1,241 @@ +# y-cluster + +Idempotent Kubernetes convergence with dependency ordering and checks. + +## Core concept + +y-cluster has one fundamental operation: + +``` +y-cluster yconverge -k path/to/base/ +``` + +This applies a kustomize base to the cluster and runs checks defined +in `yconverge.cue` files found in the base's directory tree. + +Two separate mechanisms control **what gets checked** and **what gets +converged first**. Understanding the difference is essential. + +### Checks: kustomize tree traversal + +After applying a base, y-cluster walks the kustomize directory tree +to find all `yconverge.cue` files. Checks from every local base +directory run after the apply. This is **check aggregation** — it +answers "what must be true after this apply?" + +Example: `site-apply-namespaced/` references `../site-apply/` which +has a `yconverge.cue` with a rollout check. The check runs after the +combined kustomize output is applied, because the check belongs to +the resources that were applied. + +Traversal only follows local directories. Remote refs (github URLs, +HTTP resources) are skipped — they contribute resources to the +kustomize build but their checks are not aggregated. + +### Dependencies: CUE imports + +Before applying a base, y-cluster reads CUE import statements in +`yconverge.cue` to build a dependency graph. Each dependency is +converged as a **separate yconverge invocation** — its own apply +and its own checks — before the target base. + +Example: keycloak's `yconverge.cue` imports the mysql CUE module. +y-cluster converges mysql first (apply mysql resources, run mysql +checks), then converges keycloak (apply keycloak resources, run +keycloak checks). These are two separate apply+check cycles. + +### Why the distinction matters + +Kustomize apply is atomic — all resources in the kustomize output +are applied at once. Checks run after the entire apply completes. +There is no way to check an intermediate state within a single +kustomize apply. + +CUE imports create separate convergence steps. Each step has its +own apply and checks. This is how you express "mysql must be healthy +before keycloak starts." + +The rule: + +- **kustomize resources** are for customization — overlays, patches, + namespace scoping, image overrides. They produce a single atomic + apply. Checks from the entire tree verify the result. + +- **CUE imports** are for ordering — they declare dependencies + between independently convergeable bases. Each dependency is + a separate yconverge invocation with its own checks. + +Do not use kustomize `resources:` to bundle independent modules +that need ordered convergence. Use CUE imports instead. + +### Super bases + +A convergence target can have an empty `kustomization.yaml` (no +resources to apply) and a `yconverge.cue` that imports multiple +bases. Running yconverge on it converges all imports in dependency +order, applies nothing (empty kustomization), and runs any +top-level checks. + +This is a clean way to define "converge these bases together": + +```yaml +# converge-default/kustomization.yaml +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# No resources — this is a convergence orchestration target +``` + +```cue +// converge-default/yconverge.cue +package converge_default + +import ( + "yolean.se/ystack/k3s/29-y-kustomize:y_kustomize" + "yolean.se/ystack/k3s/30-blobs:blobs" + "yolean.se/ystack/k3s/60-builds-registry:builds_registry" +) + +_dep_kustomize: y_kustomize.step +_dep_blobs: blobs.step +_dep_registry: builds_registry.step + +step: verify.#Step & { + checks: [] +} +``` + +``` +y-cluster yconverge -k converge-default/ +``` + +This replaces a comma-separated list of targets with a declarative +dependency graph. The tool resolves the imports, converges each base +in topological order, and exits. + +## Usage + +``` +# Apply a base with checks +y-cluster yconverge --context=local -k path/to/base/ + +# Check only (no apply) +y-cluster yconverge --context=local --checks-only -k path/to/base/ + +# Print dependency order +y-cluster yconverge --context=local --print-deps -k path/to/base/ + +# Dry run (validate against API server, no mutation) +y-cluster yconverge --context=local --dry-run=server -k path/to/base/ + +# Inspect kustomization tree +y-cluster traverse -k path/to/base/ +y-cluster traverse -k path/to/base/ --namespace + +# Image management +y-cluster images list -k path/to/base/ +y-cluster images cache -k path/to/base/ +y-cluster images load -k path/to/base/ + +# Cluster provisioning +y-cluster provision --provider=qemu +y-cluster teardown +``` + +## Check types + +Checks are defined in `yconverge.cue` next to `kustomization.yaml`: + +```cue +package my_base + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [ + { + kind: "rollout" + resource: "deployment/my-app" + timeout: "120s" + }, + { + kind: "exec" + command: "curl -sf http://$NAMESPACE.example.com/" + timeout: "60s" + description: "app responds" + }, + ] +} +``` + +Three check types: +- **wait** — `kubectl wait --for=` on a resource +- **rollout** — `kubectl rollout status` on a deployment/statefulset +- **exec** — arbitrary shell command, retried until timeout + +Environment variables available to exec commands: +- `$CONTEXT` — Kubernetes context name +- `$NAMESPACE` — resolved namespace for this base + +## Suggested ystack super bases + +These replace the `--converge=LIST` bash pattern with declarative +CUE dependency graphs. Each is an empty kustomization + yconverge.cue. + +### converge-default + +The minimal ystack infrastructure. Equivalent to the current +default `y-kustomize,blobs,builds-registry`. + +Since `builds-registry` already imports `blobs`, `kafka-ystack`, +and `y-kustomize` via CUE, a single yconverge call resolves the +full chain. The super base just makes this explicit: + +```cue +// k3s/converge-default/yconverge.cue +import "yolean.se/ystack/k3s/60-builds-registry:builds_registry" +_dep: builds_registry.step +step: verify.#Step & { checks: [] } +``` + +### converge-with-kafka + +Default infrastructure plus kafka (redpanda). For dependents +that need topic creation. + +```cue +// k3s/converge-with-kafka/yconverge.cue +import ( + "yolean.se/ystack/k3s/60-builds-registry:builds_registry" + "yolean.se/ystack/k3s/40-kafka:kafka" +) +_dep_registry: builds_registry.step +_dep_kafka: kafka.step +step: verify.#Step & { checks: [] } +``` + +### converge-with-buildkit + +Full build infrastructure. For dependents that build images +via skaffold/buildkitd. + +```cue +// k3s/converge-with-buildkit/yconverge.cue +import ( + "yolean.se/ystack/k3s/62-buildkit:buildkit" + "yolean.se/ystack/k3s/40-kafka:kafka" +) +_dep_buildkit: buildkit.step +_dep_kafka: kafka.step +step: verify.#Step & { checks: [] } +``` + +Since `buildkit` imports `builds-registry` which imports `blobs` +and `y-kustomize`, the full chain is resolved from two leaf imports. + +### Usage from a dependent + +```bash +y-cluster yconverge --context=local -k "$YSTACK_HOME/k3s/converge-with-kafka/" +``` + +One command, full dependency resolution, per-step checks. From e1c9a9f95b1a8e47d0c2a7a5ee42ddd7caca7540 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 04:43:54 +0000 Subject: [PATCH 05/43] docs: library usage, testing strategy, Envoy Gateway, logging --- README.md | 117 ++++++++++++------------------------------------------ 1 file changed, 26 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index e97364e..48f8b1c 100644 --- a/README.md +++ b/README.md @@ -13,8 +13,20 @@ y-cluster yconverge -k path/to/base/ This applies a kustomize base to the cluster and runs checks defined in `yconverge.cue` files found in the base's directory tree. -Two separate mechanisms control **what gets checked** and **what gets -converged first**. Understanding the difference is essential. +Two separate mechanisms control **what gets converged first** and +**what gets checked**. Understanding the difference is essential. + +### Dependencies: CUE imports + +Before applying a base, y-cluster reads CUE import statements in +`yconverge.cue` to build a dependency graph. Each dependency is +converged as a **separate yconverge invocation** — its own apply +and its own checks — before the target base. + +Example: keycloak's `yconverge.cue` imports the mysql CUE module. +y-cluster converges mysql first (apply mysql resources, run mysql +checks), then converges keycloak (apply keycloak resources, run +keycloak checks). These are two separate apply+check cycles. ### Checks: kustomize tree traversal @@ -32,41 +44,31 @@ Traversal only follows local directories. Remote refs (github URLs, HTTP resources) are skipped — they contribute resources to the kustomize build but their checks are not aggregated. -### Dependencies: CUE imports - -Before applying a base, y-cluster reads CUE import statements in -`yconverge.cue` to build a dependency graph. Each dependency is -converged as a **separate yconverge invocation** — its own apply -and its own checks — before the target base. - -Example: keycloak's `yconverge.cue` imports the mysql CUE module. -y-cluster converges mysql first (apply mysql resources, run mysql -checks), then converges keycloak (apply keycloak resources, run -keycloak checks). These are two separate apply+check cycles. - ### Why the distinction matters +CUE imports create separate convergence steps. Each step has its +own apply and checks. This is how you express "mysql must be healthy +before keycloak starts." + Kustomize apply is atomic — all resources in the kustomize output are applied at once. Checks run after the entire apply completes. There is no way to check an intermediate state within a single kustomize apply. -CUE imports create separate convergence steps. Each step has its -own apply and checks. This is how you express "mysql must be healthy -before keycloak starts." - The rule: -- **kustomize resources** are for customization — overlays, patches, - namespace scoping, image overrides. They produce a single atomic - apply. Checks from the entire tree verify the result. - - **CUE imports** are for ordering — they declare dependencies between independently convergeable bases. Each dependency is a separate yconverge invocation with its own checks. -Do not use kustomize `resources:` to bundle independent modules -that need ordered convergence. Use CUE imports instead. +- **kustomize resources** are for customization — overlays, patches, + namespace scoping, image overrides. They produce a single atomic + apply. Checks from the entire tree verify the result. + +**We recommend that kustomize is not used for bundling.** Kustomize +resources should customize a single base — not aggregate independent +modules into one apply. If two modules need ordered convergence, they +are separate yconverge targets connected by CUE imports. ### Super bases @@ -127,10 +129,6 @@ y-cluster yconverge --context=local --print-deps -k path/to/base/ # Dry run (validate against API server, no mutation) y-cluster yconverge --context=local --dry-run=server -k path/to/base/ -# Inspect kustomization tree -y-cluster traverse -k path/to/base/ -y-cluster traverse -k path/to/base/ --namespace - # Image management y-cluster images list -k path/to/base/ y-cluster images cache -k path/to/base/ @@ -176,66 +174,3 @@ Environment variables available to exec commands: - `$CONTEXT` — Kubernetes context name - `$NAMESPACE` — resolved namespace for this base -## Suggested ystack super bases - -These replace the `--converge=LIST` bash pattern with declarative -CUE dependency graphs. Each is an empty kustomization + yconverge.cue. - -### converge-default - -The minimal ystack infrastructure. Equivalent to the current -default `y-kustomize,blobs,builds-registry`. - -Since `builds-registry` already imports `blobs`, `kafka-ystack`, -and `y-kustomize` via CUE, a single yconverge call resolves the -full chain. The super base just makes this explicit: - -```cue -// k3s/converge-default/yconverge.cue -import "yolean.se/ystack/k3s/60-builds-registry:builds_registry" -_dep: builds_registry.step -step: verify.#Step & { checks: [] } -``` - -### converge-with-kafka - -Default infrastructure plus kafka (redpanda). For dependents -that need topic creation. - -```cue -// k3s/converge-with-kafka/yconverge.cue -import ( - "yolean.se/ystack/k3s/60-builds-registry:builds_registry" - "yolean.se/ystack/k3s/40-kafka:kafka" -) -_dep_registry: builds_registry.step -_dep_kafka: kafka.step -step: verify.#Step & { checks: [] } -``` - -### converge-with-buildkit - -Full build infrastructure. For dependents that build images -via skaffold/buildkitd. - -```cue -// k3s/converge-with-buildkit/yconverge.cue -import ( - "yolean.se/ystack/k3s/62-buildkit:buildkit" - "yolean.se/ystack/k3s/40-kafka:kafka" -) -_dep_buildkit: buildkit.step -_dep_kafka: kafka.step -step: verify.#Step & { checks: [] } -``` - -Since `buildkit` imports `builds-registry` which imports `blobs` -and `y-kustomize`, the full chain is resolved from two leaf imports. - -### Usage from a dependent - -```bash -y-cluster yconverge --context=local -k "$YSTACK_HOME/k3s/converge-with-kafka/" -``` - -One command, full dependency resolution, per-step checks. From ccec90e53803936379e5de2b40c7dc7b320373fe Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 04:44:19 +0000 Subject: [PATCH 06/43] refactor: extract traverse package, rename module to y-cluster --- go.mod | 2 +- main.go | 166 +----------- pkg/kustomize/traverse/traverse.go | 207 +++++++++++++++ pkg/kustomize/traverse/traverse_test.go | 320 ++++++++++++++++++++++++ 4 files changed, 537 insertions(+), 158 deletions(-) create mode 100644 pkg/kustomize/traverse/traverse.go create mode 100644 pkg/kustomize/traverse/traverse_test.go diff --git a/go.mod b/go.mod index 0e9d93d..d884464 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/Yolean/kustomize-traverse +module github.com/Yolean/y-cluster go 1.26.1 diff --git a/main.go b/main.go index f5ebd92..001143f 100644 --- a/main.go +++ b/main.go @@ -6,11 +6,8 @@ import ( "fmt" "io" "os" - "path/filepath" - "strings" - "sigs.k8s.io/kustomize/api/types" - "sigs.k8s.io/yaml" + "github.com/Yolean/y-cluster/pkg/kustomize/traverse" ) const usage = `Usage: kustomize-traverse [flags] @@ -42,44 +39,24 @@ func run(args []string, stdout, stderr io.Writer) int { return code } - kust, _, err := loadKustomization(opts.path) - if err != nil { - fmt.Fprintf(stderr, "error: %v\n", err) - return 1 - } - if kust == nil { - fmt.Fprintf(stderr, "error: no kustomization file in %s\n", opts.path) - return 1 - } - - warn := func(format string, a ...any) { - if !opts.quiet { + var warn traverse.WarnFunc + if !opts.quiet { + warn = func(format string, a ...any) { fmt.Fprintf(stderr, "warning: "+format+"\n", a...) } } - rootAbs, err := filepath.Abs(opts.path) + result, err := traverse.Walk(opts.path, warn) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } - dirs, err := collectDirs(rootAbs, warn) + rels, err := result.RelDirs(opts.path) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return 1 } - rels := make([]string, 0, len(dirs)) - for _, d := range dirs { - rel, err := filepath.Rel(rootAbs, d) - if err != nil { - fmt.Fprintf(stderr, "error: %v\n", err) - return 1 - } - rels = append(rels, rel) - } - - ns := resolveNamespace(rootAbs) switch opts.output { case "dirs": @@ -87,14 +64,14 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stdout, r) } case "namespace": - if ns != "" { - fmt.Fprintln(stdout, ns) + if result.Namespace != "" { + fmt.Fprintln(stdout, result.Namespace) } case "json": out := struct { Namespace string `json:"namespace"` Dirs []string `json:"dirs"` - }{Namespace: ns, Dirs: rels} + }{Namespace: result.Namespace, Dirs: rels} enc := json.NewEncoder(stdout) enc.SetIndent("", " ") if err := enc.Encode(&out); err != nil { @@ -132,128 +109,3 @@ func parseArgs(args []string, stderr io.Writer) (options, int, error) { opts.path = rest[0] return opts, 0, nil } - -// loadKustomization reads the kustomization file in dir, returning the parsed -// struct, the file path that was read, and any error. Returns (nil, "", nil) -// if no kustomization file exists. -func loadKustomization(dir string) (*types.Kustomization, string, error) { - for _, name := range []string{"kustomization.yaml", "kustomization.yml", "Kustomization"} { - p := filepath.Join(dir, name) - data, err := os.ReadFile(p) - if err != nil { - if os.IsNotExist(err) { - continue - } - return nil, p, err - } - var k types.Kustomization - if err := yaml.Unmarshal(data, &k); err != nil { - return nil, p, fmt.Errorf("parse %s: %w", p, err) - } - k.FixKustomization() - return &k, p, nil - } - return nil, "", nil -} - -func hasKustomization(dir string) bool { - for _, name := range []string{"kustomization.yaml", "kustomization.yml", "Kustomization"} { - if _, err := os.Stat(filepath.Join(dir, name)); err == nil { - return true - } - } - return false -} - -// isRemote detects refs that are not local filesystem paths. kustomize -// accepts URLs (http://, https://, git://, ssh://), git+SSH forms -// (git@host:path), and shorthand like github.com/owner/repo//path. The -// common signal is either a scheme (://), git@ prefix, or a first path -// segment that looks like a domain (contains a dot). -func isRemote(entry string) bool { - if strings.Contains(entry, "://") { - return true - } - if strings.HasPrefix(entry, "git@") { - return true - } - first := entry - if i := strings.Index(entry, "/"); i >= 0 { - first = entry[:i] - } - return strings.Contains(first, ".") && !strings.HasPrefix(first, ".") -} - -// localBases returns the absolute paths of resources/components entries -// that resolve to local directories containing a kustomization file. -func localBases(dir string, k *types.Kustomization, warn func(string, ...any)) []string { - var bases []string - entries := append([]string{}, k.Resources...) - entries = append(entries, k.Components...) - for _, e := range entries { - if isRemote(e) { - continue - } - abs := filepath.Clean(filepath.Join(dir, e)) - info, err := os.Stat(abs) - if err != nil { - if warn != nil { - warn("skipping unresolvable ref %q from %s", e, dir) - } - continue - } - if !info.IsDir() { - continue - } - if !hasKustomization(abs) { - continue - } - bases = append(bases, abs) - } - return bases -} - -func collectDirs(rootAbs string, warn func(string, ...any)) ([]string, error) { - visited := map[string]bool{} - var results []string - if err := walk(rootAbs, visited, &results, warn); err != nil { - return nil, err - } - return results, nil -} - -func walk(dirAbs string, visited map[string]bool, results *[]string, warn func(string, ...any)) error { - if visited[dirAbs] { - return nil - } - visited[dirAbs] = true - k, _, err := loadKustomization(dirAbs) - if err != nil { - return err - } - if k == nil { - return nil - } - for _, base := range localBases(dirAbs, k, warn) { - if err := walk(base, visited, results, warn); err != nil { - return err - } - } - *results = append(*results, dirAbs) - return nil -} - -func resolveNamespace(dirAbs string) string { - k, _, err := loadKustomization(dirAbs) - if err != nil || k == nil { - return "" - } - if k.Namespace != "" { - return k.Namespace - } - bases := localBases(dirAbs, k, nil) - if len(bases) == 1 { - return resolveNamespace(bases[0]) - } - return "" -} diff --git a/pkg/kustomize/traverse/traverse.go b/pkg/kustomize/traverse/traverse.go new file mode 100644 index 0000000..bd1eca7 --- /dev/null +++ b/pkg/kustomize/traverse/traverse.go @@ -0,0 +1,207 @@ +// Package traverse walks kustomization directory trees and reports +// structural metadata: the set of local directories visited and the +// resolved namespace at each level. +package traverse + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "sigs.k8s.io/kustomize/api/types" + "sigs.k8s.io/yaml" +) + +// Result holds the output of a kustomization tree walk. +type Result struct { + // Namespace resolved from the kustomization tree. + // Empty string if no namespace is declared. + Namespace string + + // Dirs lists all local directories visited, in depth-first order. + // Paths are absolute. The target directory is always last. + Dirs []string +} + +// WarnFunc is called for non-fatal issues during traversal. +type WarnFunc func(format string, a ...any) + +// Walk traverses the kustomization tree rooted at dir and returns +// the list of local directories visited (depth-first, deduplicated) +// and the resolved namespace. +func Walk(dir string, warn WarnFunc) (*Result, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("resolve path %s: %w", dir, err) + } + + k, _, err := LoadKustomization(abs) + if err != nil { + return nil, err + } + if k == nil { + return nil, fmt.Errorf("no kustomization file in %s", dir) + } + + dirs, err := collectDirs(abs, warn) + if err != nil { + return nil, err + } + + ns := resolveNamespace(abs) + + return &Result{ + Namespace: ns, + Dirs: dirs, + }, nil +} + +// RelDirs returns the directory list as paths relative to the given root. +func (r *Result) RelDirs(root string) ([]string, error) { + abs, err := filepath.Abs(root) + if err != nil { + return nil, err + } + rels := make([]string, 0, len(r.Dirs)) + for _, d := range r.Dirs { + rel, err := filepath.Rel(abs, d) + if err != nil { + return nil, err + } + rels = append(rels, rel) + } + return rels, nil +} + +// LoadKustomization reads the kustomization file in dir, returning the +// parsed struct, the file path that was read, and any error. +// Returns (nil, "", nil) if no kustomization file exists. +func LoadKustomization(dir string) (*types.Kustomization, string, error) { + for _, name := range kustomizationFiles { + p := filepath.Join(dir, name) + data, err := os.ReadFile(p) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, p, err + } + var k types.Kustomization + if err := yaml.Unmarshal(data, &k); err != nil { + return nil, p, fmt.Errorf("parse %s: %w", p, err) + } + k.FixKustomization() + return &k, p, nil + } + return nil, "", nil +} + +// HasKustomization checks if dir contains a kustomization file. +func HasKustomization(dir string) bool { + for _, name := range kustomizationFiles { + if _, err := os.Stat(filepath.Join(dir, name)); err == nil { + return true + } + } + return false +} + +var kustomizationFiles = []string{ + "kustomization.yaml", + "kustomization.yml", + "Kustomization", +} + +// IsRemote detects refs that are not local filesystem paths. +// Remote refs have a scheme (://), git@ prefix, or a first path +// segment that looks like a domain (contains a dot, has a slash +// after it). A bare filename like "deployment.yaml" is not remote. +func IsRemote(entry string) bool { + if strings.Contains(entry, "://") { + return true + } + if strings.HasPrefix(entry, "git@") { + return true + } + i := strings.Index(entry, "/") + if i < 0 { + return false // no slash = local file or dir, never a domain + } + first := entry[:i] + return strings.Contains(first, ".") && !strings.HasPrefix(first, ".") +} + +// LocalBases returns the absolute paths of resources/components entries +// that resolve to local directories containing a kustomization file. +func LocalBases(dir string, k *types.Kustomization, warn WarnFunc) []string { + var bases []string + entries := append([]string{}, k.Resources...) + entries = append(entries, k.Components...) + for _, e := range entries { + if IsRemote(e) { + continue + } + abs := filepath.Clean(filepath.Join(dir, e)) + info, err := os.Stat(abs) + if err != nil { + if warn != nil { + warn("skipping unresolvable ref %q from %s", e, dir) + } + continue + } + if !info.IsDir() { + continue + } + if !HasKustomization(abs) { + continue + } + bases = append(bases, abs) + } + return bases +} + +func collectDirs(rootAbs string, warn WarnFunc) ([]string, error) { + visited := map[string]bool{} + var results []string + if err := walkTree(rootAbs, visited, &results, warn); err != nil { + return nil, err + } + return results, nil +} + +func walkTree(dirAbs string, visited map[string]bool, results *[]string, warn WarnFunc) error { + if visited[dirAbs] { + return nil + } + visited[dirAbs] = true + k, _, err := LoadKustomization(dirAbs) + if err != nil { + return err + } + if k == nil { + return nil + } + for _, base := range LocalBases(dirAbs, k, warn) { + if err := walkTree(base, visited, results, warn); err != nil { + return err + } + } + *results = append(*results, dirAbs) + return nil +} + +func resolveNamespace(dirAbs string) string { + k, _, err := LoadKustomization(dirAbs) + if err != nil || k == nil { + return "" + } + if k.Namespace != "" { + return k.Namespace + } + bases := LocalBases(dirAbs, k, nil) + if len(bases) == 1 { + return resolveNamespace(bases[0]) + } + return "" +} diff --git a/pkg/kustomize/traverse/traverse_test.go b/pkg/kustomize/traverse/traverse_test.go new file mode 100644 index 0000000..1bc3844 --- /dev/null +++ b/pkg/kustomize/traverse/traverse_test.go @@ -0,0 +1,320 @@ +package traverse + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFiles(t *testing.T, root string, files map[string]string) { + t.Helper() + for rel, content := range files { + p := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func relDirs(t *testing.T, result *Result, root string) []string { + t.Helper() + rels, err := result.RelDirs(root) + if err != nil { + t.Fatal(err) + } + return rels +} + +func TestWalk_DirsHappyPath(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "base/kustomization.yaml": "namespace: dev\nresources:\n- deployment.yaml\n", + "base/deployment.yaml": "kind: Deployment\n", + "overlay/kustomization.yaml": "resources:\n- ../base\n", + }) + + result, err := Walk(filepath.Join(root, "overlay"), nil) + if err != nil { + t.Fatal(err) + } + got := relDirs(t, result, filepath.Join(root, "overlay")) + want := []string{"../base", "."} + if !equalStrings(got, want) { + t.Fatalf("dirs: got %v want %v", got, want) + } +} + +func TestWalk_NamespaceOutermostWins(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "base/kustomization.yaml": "namespace: base-ns\n", + "overlay/kustomization.yaml": "namespace: overlay-ns\nresources:\n- ../base\n", + }) + result, err := Walk(filepath.Join(root, "overlay"), nil) + if err != nil { + t.Fatal(err) + } + if result.Namespace != "overlay-ns" { + t.Fatalf("namespace=%q want overlay-ns", result.Namespace) + } +} + +func TestWalk_NamespaceFallsBackThroughSingleBase(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "site-apply/kustomization.yaml": "namespace: dev\n", + "site-apply-namespaced/kustomization.yaml": "resources:\n- ../site-apply\n", + }) + result, err := Walk(filepath.Join(root, "site-apply-namespaced"), nil) + if err != nil { + t.Fatal(err) + } + if result.Namespace != "dev" { + t.Fatalf("namespace=%q want dev", result.Namespace) + } +} + +func TestWalk_NamespaceEmptyWhenMultipleBases(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "a/kustomization.yaml": "", + "b/kustomization.yaml": "", + "root/kustomization.yaml": "resources:\n- ../a\n- ../b\n", + }) + result, err := Walk(filepath.Join(root, "root"), nil) + if err != nil { + t.Fatal(err) + } + if result.Namespace != "" { + t.Fatalf("expected empty namespace, got %q", result.Namespace) + } +} + +func TestWalk_ComponentsAreTraversed(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "comp/kustomization.yaml": "apiVersion: kustomize.config.k8s.io/v1alpha1\nkind: Component\n", + "overlay/kustomization.yaml": "components:\n- ../comp\n", + }) + result, err := Walk(filepath.Join(root, "overlay"), nil) + if err != nil { + t.Fatal(err) + } + got := relDirs(t, result, filepath.Join(root, "overlay")) + want := []string{"../comp", "."} + if !equalStrings(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestWalk_LegacyBasesField(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "base/kustomization.yaml": "namespace: legacy\n", + "overlay/kustomization.yaml": "bases:\n- ../base\n", + }) + result, err := Walk(filepath.Join(root, "overlay"), nil) + if err != nil { + t.Fatal(err) + } + if result.Namespace != "legacy" { + t.Fatalf("namespace=%q want legacy", result.Namespace) + } +} + +func TestWalk_DedupeAcrossOverlayAndBase(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "shared/kustomization.yaml": "", + "a/kustomization.yaml": "resources:\n- ../shared\n", + "b/kustomization.yaml": "resources:\n- ../shared\n", + "top/kustomization.yaml": "resources:\n- ../a\n- ../b\n", + }) + result, err := Walk(filepath.Join(root, "top"), nil) + if err != nil { + t.Fatal(err) + } + got := relDirs(t, result, filepath.Join(root, "top")) + want := []string{"../shared", "../a", "../b", "."} + if !equalStrings(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestWalk_DepthFirstBasesBeforeOverlay(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "leaf/kustomization.yaml": "", + "mid/kustomization.yaml": "resources:\n- ../leaf\n", + "top/kustomization.yaml": "resources:\n- ../mid\n", + }) + result, err := Walk(filepath.Join(root, "top"), nil) + if err != nil { + t.Fatal(err) + } + got := relDirs(t, result, filepath.Join(root, "top")) + want := []string{"../leaf", "../mid", "."} + if !equalStrings(got, want) { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestWalk_RemoteRefsSkippedSilently(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "overlay/kustomization.yaml": strings.Join([]string{ + "resources:", + "- github.com/owner/repo//path?ref=v1", + "- https://example.com/thing.yaml", + "- git@github.com:owner/repo.git", + }, "\n") + "\n", + }) + var warnings []string + warn := func(format string, a ...any) { + warnings = append(warnings, format) + } + result, err := Walk(filepath.Join(root, "overlay"), warn) + if err != nil { + t.Fatal(err) + } + got := relDirs(t, result, filepath.Join(root, "overlay")) + if len(got) != 1 || got[0] != "." { + t.Fatalf("got %v, want [.]", got) + } + if len(warnings) > 0 { + t.Fatalf("remote refs should not warn: %v", warnings) + } +} + +func TestWalk_ResourceFilesAreSkipped(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "overlay/kustomization.yaml": "resources:\n- deployment.yaml\n- service.yaml\n", + "overlay/deployment.yaml": "kind: Deployment\n", + "overlay/service.yaml": "kind: Service\n", + }) + result, err := Walk(filepath.Join(root, "overlay"), nil) + if err != nil { + t.Fatal(err) + } + got := relDirs(t, result, filepath.Join(root, "overlay")) + if len(got) != 1 || got[0] != "." { + t.Fatalf("got %v, want [.]", got) + } +} + +func TestWalk_MissingDirWarns(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "overlay/kustomization.yaml": "resources:\n- ../gone\n", + }) + var warnings []string + warn := func(format string, a ...any) { + warnings = append(warnings, format) + } + result, err := Walk(filepath.Join(root, "overlay"), warn) + if err != nil { + t.Fatal(err) + } + if len(warnings) == 0 { + t.Fatal("expected warning for missing dir") + } + got := relDirs(t, result, filepath.Join(root, "overlay")) + if len(got) != 1 || got[0] != "." { + t.Fatalf("got %v", got) + } +} + +func TestWalk_MissingDirSilentWithNilWarn(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "overlay/kustomization.yaml": "resources:\n- ../gone\n", + }) + result, err := Walk(filepath.Join(root, "overlay"), nil) + if err != nil { + t.Fatal(err) + } + got := relDirs(t, result, filepath.Join(root, "overlay")) + if len(got) != 1 || got[0] != "." { + t.Fatalf("got %v", got) + } +} + +func TestWalk_NoKustomizationFile(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil { + t.Fatal(err) + } + _, err := Walk(filepath.Join(root, "empty"), nil) + if err == nil { + t.Fatal("expected error for missing kustomization") + } + if !strings.Contains(err.Error(), "no kustomization") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestWalk_InvalidYaml(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "overlay/kustomization.yaml": ":\n not valid\n\tcontent\n", + }) + _, err := Walk(filepath.Join(root, "overlay"), nil) + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} + +func TestWalk_KustomizationYmlExtension(t *testing.T) { + root := t.TempDir() + writeFiles(t, root, map[string]string{ + "overlay/kustomization.yml": "namespace: via-yml\n", + }) + result, err := Walk(filepath.Join(root, "overlay"), nil) + if err != nil { + t.Fatal(err) + } + if result.Namespace != "via-yml" { + t.Fatalf("namespace=%q want via-yml", result.Namespace) + } +} + +func TestIsRemote(t *testing.T) { + tests := []struct { + entry string + remote bool + }{ + {"../base", false}, + {"./local", false}, + {"deployment.yaml", false}, + {".hidden/dir", false}, + {"github.com/owner/repo//path?ref=v1", true}, + {"https://example.com/thing.yaml", true}, + {"http://y-kustomize.svc/v1/base.yaml", true}, + {"git@github.com:owner/repo.git", true}, + {"git://example.com/repo", true}, + } + for _, tt := range tests { + t.Run(tt.entry, func(t *testing.T) { + if got := IsRemote(tt.entry); got != tt.remote { + t.Errorf("IsRemote(%q) = %v, want %v", tt.entry, got, tt.remote) + } + }) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} From 0c4c6f665bb82cdcb5fdd09ad8206a08bbfee3c6 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Thu, 23 Apr 2026 15:32:34 +0000 Subject: [PATCH 07/43] feat: add yconverge package with CUE parsing and check runner --- go.mod | 21 +++- go.sum | 52 +++++++++- pkg/yconverge/checks.go | 178 +++++++++++++++++++++++++++++++++ pkg/yconverge/checks_test.go | 189 +++++++++++++++++++++++++++++++++++ pkg/yconverge/cue.go | 107 ++++++++++++++++++++ pkg/yconverge/cue_test.go | 122 ++++++++++++++++++++++ 6 files changed, 663 insertions(+), 6 deletions(-) create mode 100644 pkg/yconverge/checks.go create mode 100644 pkg/yconverge/checks_test.go create mode 100644 pkg/yconverge/cue.go create mode 100644 pkg/yconverge/cue_test.go diff --git a/go.mod b/go.mod index d884464..bf74e10 100644 --- a/go.mod +++ b/go.mod @@ -3,22 +3,39 @@ module github.com/Yolean/y-cluster go 1.26.1 require ( + cuelang.org/go v0.16.1 + go.uber.org/zap v1.27.1 sigs.k8s.io/kustomize/api v0.21.1 sigs.k8s.io/yaml v1.5.0 ) require ( + cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 // indirect + github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/proto v1.14.3 // indirect github.com/go-errors/errors v1.4.2 // 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.6.9 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect - go.yaml.in/yaml/v3 v3.0.3 // indirect - golang.org/x/sys v0.35.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect google.golang.org/protobuf v1.36.5 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect diff --git a/go.sum b/go.sum index afb9ea6..15e9ce6 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,15 @@ +cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I= +cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8= +cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE= +cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM= +github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= 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/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q= +github.com/emicklei/proto v1.14.3/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= @@ -12,12 +20,16 @@ github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En 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-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= +github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= 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.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -27,10 +39,24 @@ 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/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 h1:2PC6Ql3jipz1KvBlqUHjjk6v4aMwE86mfDu1XMH0LR8= +github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -41,12 +67,30 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= -go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= -golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/yconverge/checks.go b/pkg/yconverge/checks.go new file mode 100644 index 0000000..961dfe5 --- /dev/null +++ b/pkg/yconverge/checks.go @@ -0,0 +1,178 @@ +// Package yconverge provides idempotent Kubernetes convergence with +// CUE-based dependency resolution and post-apply checks. +package yconverge + +import ( + "context" + "fmt" + "os/exec" + "time" + + "go.uber.org/zap" +) + +// Check represents a single post-apply verification step. +type Check struct { + Kind string `json:"kind"` + Resource string `json:"resource,omitempty"` + For string `json:"for,omitempty"` + Namespace string `json:"namespace,omitempty"` + Timeout string `json:"timeout,omitempty"` + Command string `json:"command,omitempty"` + Description string `json:"description,omitempty"` +} + +// DefaultTimeout is used when a check does not specify a timeout. +const DefaultTimeout = "60s" + +// CheckRunner executes checks against a Kubernetes cluster. +type CheckRunner struct { + Context string // Kubernetes context name + Namespace string // resolved namespace + Logger *zap.Logger +} + +// RunAll executes checks in order. A failing check stops execution. +func (r *CheckRunner) RunAll(ctx context.Context, checks []Check) error { + for i, check := range checks { + if err := r.runOne(ctx, check); err != nil { + return &CheckError{ + Index: i, + Check: check, + Err: err, + } + } + } + return nil +} + +func (r *CheckRunner) runOne(ctx context.Context, check Check) error { + timeout, err := parseDuration(check.Timeout) + if err != nil { + return fmt.Errorf("invalid timeout %q: %w", check.Timeout, err) + } + + ns := check.Namespace + if ns == "" { + ns = r.Namespace + } + + switch check.Kind { + case "wait": + return r.runWait(ctx, check, ns, timeout) + case "rollout": + return r.runRollout(ctx, check, ns, timeout) + case "exec": + return r.runExec(ctx, check, timeout) + default: + return fmt.Errorf("unknown check kind: %q", check.Kind) + } +} + +func (r *CheckRunner) runWait(ctx context.Context, check Check, ns string, timeout time.Duration) error { + desc := check.Description + if desc == "" { + desc = fmt.Sprintf("wait %s %s", check.Resource, check.For) + } + r.Logger.Info("check", + zap.String("kind", "wait"), + zap.String("resource", check.Resource), + zap.String("description", desc), + ) + + args := []string{"--context=" + r.Context, "wait", + "--for=" + check.For, + "--timeout=" + formatDuration(timeout), + } + if ns != "" { + args = append(args, "-n", ns) + } + args = append(args, check.Resource) + return r.kubectl(ctx, args...) +} + +func (r *CheckRunner) runRollout(ctx context.Context, check Check, ns string, timeout time.Duration) error { + desc := check.Description + if desc == "" { + desc = fmt.Sprintf("rollout %s", check.Resource) + } + r.Logger.Info("check", + zap.String("kind", "rollout"), + zap.String("resource", check.Resource), + zap.String("description", desc), + ) + + args := []string{"--context=" + r.Context, "rollout", "status", + "--timeout=" + formatDuration(timeout), + } + if ns != "" { + args = append(args, "-n", ns) + } + args = append(args, check.Resource) + return r.kubectl(ctx, args...) +} + +func (r *CheckRunner) runExec(ctx context.Context, check Check, timeout time.Duration) error { + r.Logger.Info("check", + zap.String("kind", "exec"), + zap.String("description", check.Description), + ) + + deadline := time.Now().Add(timeout) + var lastErr error + for { + cmd := exec.CommandContext(ctx, "sh", "-c", check.Command) + cmd.Env = append(cmd.Environ(), + "CONTEXT="+r.Context, + "NAMESPACE="+r.Namespace, + ) + if err := cmd.Run(); err == nil { + return nil + } else { + lastErr = err + } + if time.Now().After(deadline) { + return fmt.Errorf("exec check timed out after %s: %w", timeout, lastErr) + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +func (r *CheckRunner) kubectl(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "kubectl", args...) + cmd.Stdout = nil + cmd.Stderr = nil + return cmd.Run() +} + +func parseDuration(s string) (time.Duration, error) { + if s == "" { + s = DefaultTimeout + } + return time.ParseDuration(s) +} + +func formatDuration(d time.Duration) string { + return fmt.Sprintf("%ds", int(d.Seconds())) +} + +// CheckError wraps a check failure with index and check context. +type CheckError struct { + Index int + Check Check + Err error +} + +func (e *CheckError) Error() string { + desc := e.Check.Description + if desc == "" { + desc = fmt.Sprintf("%s %s", e.Check.Kind, e.Check.Resource) + } + return fmt.Sprintf("check %d (%s): %v", e.Index, desc, e.Err) +} + +func (e *CheckError) Unwrap() error { return e.Err } diff --git a/pkg/yconverge/checks_test.go b/pkg/yconverge/checks_test.go new file mode 100644 index 0000000..dd7fee6 --- /dev/null +++ b/pkg/yconverge/checks_test.go @@ -0,0 +1,189 @@ +package yconverge + +import ( + "context" + "testing" + "time" + + "go.uber.org/zap" +) + +func testLogger(t *testing.T) *zap.Logger { + t.Helper() + logger, err := zap.NewDevelopment() + if err != nil { + t.Fatal(err) + } + return logger +} + +func TestCheckRunner_ExecSuccess(t *testing.T) { + runner := &CheckRunner{ + Context: "test", + Namespace: "default", + Logger: testLogger(t), + } + checks := []Check{{ + Kind: "exec", + Command: "true", + Timeout: "5s", + Description: "always succeeds", + }} + if err := runner.RunAll(context.Background(), checks); err != nil { + t.Fatalf("expected success: %v", err) + } +} + +func TestCheckRunner_ExecFailure(t *testing.T) { + runner := &CheckRunner{ + Context: "test", + Namespace: "default", + Logger: testLogger(t), + } + checks := []Check{{ + Kind: "exec", + Command: "false", + Timeout: "3s", + Description: "always fails", + }} + err := runner.RunAll(context.Background(), checks) + if err == nil { + t.Fatal("expected error") + } + checkErr, ok := err.(*CheckError) + if !ok { + t.Fatalf("expected CheckError, got %T", err) + } + if checkErr.Index != 0 { + t.Fatalf("expected index 0, got %d", checkErr.Index) + } +} + +func TestCheckRunner_ExecRetries(t *testing.T) { + // Create a file that the command checks — first calls fail, last succeeds + dir := t.TempDir() + runner := &CheckRunner{ + Context: "test", + Namespace: "default", + Logger: testLogger(t), + } + + // Command that succeeds on 2nd+ attempt (creates a marker file on first run) + checks := []Check{{ + Kind: "exec", + Command: "test -f " + dir + "/marker || (touch " + dir + "/marker && false)", + Timeout: "10s", + Description: "fails first, succeeds after retry", + }} + + start := time.Now() + if err := runner.RunAll(context.Background(), checks); err != nil { + t.Fatalf("expected success after retry: %v", err) + } + elapsed := time.Since(start) + // Should have retried at least once (2s interval) + if elapsed < 2*time.Second { + t.Fatalf("expected retry delay, elapsed=%v", elapsed) + } +} + +func TestCheckRunner_ExecUsesNamespaceEnv(t *testing.T) { + runner := &CheckRunner{ + Context: "test-ctx", + Namespace: "my-ns", + Logger: testLogger(t), + } + checks := []Check{{ + Kind: "exec", + Command: `test "$NAMESPACE" = "my-ns" && test "$CONTEXT" = "test-ctx"`, + Timeout: "5s", + Description: "env vars are set", + }} + if err := runner.RunAll(context.Background(), checks); err != nil { + t.Fatalf("expected NAMESPACE/CONTEXT env vars: %v", err) + } +} + +func TestCheckRunner_StopsOnFirstFailure(t *testing.T) { + runner := &CheckRunner{ + Context: "test", + Namespace: "default", + Logger: testLogger(t), + } + checks := []Check{ + {Kind: "exec", Command: "true", Timeout: "5s", Description: "pass"}, + {Kind: "exec", Command: "false", Timeout: "3s", Description: "fail"}, + {Kind: "exec", Command: "true", Timeout: "5s", Description: "never reached"}, + } + err := runner.RunAll(context.Background(), checks) + if err == nil { + t.Fatal("expected error") + } + checkErr := err.(*CheckError) + if checkErr.Index != 1 { + t.Fatalf("expected failure at index 1, got %d", checkErr.Index) + } +} + +func TestCheckRunner_EmptyChecks(t *testing.T) { + runner := &CheckRunner{ + Context: "test", + Namespace: "default", + Logger: testLogger(t), + } + if err := runner.RunAll(context.Background(), nil); err != nil { + t.Fatalf("expected success for empty checks: %v", err) + } +} + +func TestCheckRunner_UnknownKind(t *testing.T) { + runner := &CheckRunner{ + Context: "test", + Namespace: "default", + Logger: testLogger(t), + } + checks := []Check{{ + Kind: "unknown", + Timeout: "5s", + }} + err := runner.RunAll(context.Background(), checks) + if err == nil { + t.Fatal("expected error for unknown kind") + } +} + +func TestParseDuration_Default(t *testing.T) { + d, err := parseDuration("") + if err != nil { + t.Fatal(err) + } + if d != 60*time.Second { + t.Fatalf("expected 60s, got %v", d) + } +} + +func TestParseDuration_Explicit(t *testing.T) { + d, err := parseDuration("120s") + if err != nil { + t.Fatal(err) + } + if d != 120*time.Second { + t.Fatalf("expected 120s, got %v", d) + } +} + +func TestCheckError_Format(t *testing.T) { + err := &CheckError{ + Index: 2, + Check: Check{ + Kind: "rollout", + Resource: "deployment/app", + Description: "app ready", + }, + Err: context.DeadlineExceeded, + } + got := err.Error() + if got != "check 2 (app ready): context deadline exceeded" { + t.Fatalf("unexpected error string: %q", got) + } +} diff --git a/pkg/yconverge/cue.go b/pkg/yconverge/cue.go new file mode 100644 index 0000000..2d4bb8c --- /dev/null +++ b/pkg/yconverge/cue.go @@ -0,0 +1,107 @@ +package yconverge + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" + "cuelang.org/go/cue/load" +) + +// ParseChecks evaluates a yconverge.cue file and extracts the checks +// from step.checks. Returns an empty slice if no checks are defined. +func ParseChecks(cueDir string) ([]Check, error) { + ctx := cuecontext.New() + + cfg := &load.Config{ + Dir: cueDir, + } + instances := load.Instances([]string{"."}, cfg) + if len(instances) == 0 { + return nil, fmt.Errorf("no CUE instances found in %s", cueDir) + } + inst := instances[0] + if inst.Err != nil { + return nil, fmt.Errorf("load CUE %s: %w", cueDir, inst.Err) + } + + val := ctx.BuildInstance(inst) + if err := val.Err(); err != nil { + return nil, fmt.Errorf("build CUE %s: %w", cueDir, err) + } + + checksVal := val.LookupPath(cue.ParsePath("step.checks")) + if err := checksVal.Err(); err != nil { + // step.checks not found — no checks defined + return nil, nil + } + + checksJSON, err := checksVal.MarshalJSON() + if err != nil { + return nil, fmt.Errorf("marshal checks from %s: %w", cueDir, err) + } + + var checks []Check + if err := json.Unmarshal(checksJSON, &checks); err != nil { + return nil, fmt.Errorf("unmarshal checks from %s: %w", cueDir, err) + } + + return checks, nil +} + +// importPattern matches CUE import paths that look like ystack +// convergence dependencies (i.e. imports from the ystack CUE module, +// excluding the verify schema itself). +var importPattern = regexp.MustCompile(`"(yolean\.se/ystack/[^"]+)"`) +var verifyImport = "yolean.se/ystack/yconverge/verify" + +// ParseImports reads a yconverge.cue file and extracts dependency +// paths from CUE import statements. Returns filesystem-relative paths +// suitable for resolving to kustomize base directories. +// +// Example: import "yolean.se/ystack/k3s/30-blobs:blobs" → "k3s/30-blobs" +func ParseImports(cueFile string) ([]string, error) { + data, err := os.ReadFile(cueFile) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + matches := importPattern.FindAllStringSubmatch(string(data), -1) + var deps []string + for _, m := range matches { + imp := m[1] + if imp == verifyImport { + continue + } + // Strip the CUE package label (":name" suffix) + path := imp + if i := strings.LastIndex(path, ":"); i >= 0 { + path = path[:i] + } + // Strip the module prefix + path = strings.TrimPrefix(path, "yolean.se/ystack/") + deps = append(deps, path) + } + return deps, nil +} + +// FindCueFiles returns the paths of yconverge.cue files found in the +// given directories. Each directory is checked for a yconverge.cue file. +func FindCueFiles(dirs []string) []string { + var found []string + for _, dir := range dirs { + p := filepath.Join(dir, "yconverge.cue") + if _, err := os.Stat(p); err == nil { + found = append(found, dir) + } + } + return found +} diff --git a/pkg/yconverge/cue_test.go b/pkg/yconverge/cue_test.go new file mode 100644 index 0000000..2d6b4e8 --- /dev/null +++ b/pkg/yconverge/cue_test.go @@ -0,0 +1,122 @@ +package yconverge + +import ( + "os" + "path/filepath" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestParseImports_ExtractsDependencies(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "yconverge.cue"), ` +package test + +import ( + "yolean.se/ystack/yconverge/verify" + "yolean.se/ystack/k3s/30-blobs:blobs" + "yolean.se/ystack/k3s/40-kafka-ystack:kafka_ystack" +) + +_dep_blobs: blobs.step +_dep_kafka: kafka_ystack.step + +step: verify.#Step & { + checks: [] +} +`) + deps, err := ParseImports(filepath.Join(dir, "yconverge.cue")) + if err != nil { + t.Fatal(err) + } + want := []string{"k3s/30-blobs", "k3s/40-kafka-ystack"} + if !equalStrings(deps, want) { + t.Fatalf("got %v want %v", deps, want) + } +} + +func TestParseImports_SkipsVerifyImport(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "yconverge.cue"), ` +package test + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [] +} +`) + deps, err := ParseImports(filepath.Join(dir, "yconverge.cue")) + if err != nil { + t.Fatal(err) + } + if len(deps) != 0 { + t.Fatalf("expected no deps, got %v", deps) + } +} + +func TestParseImports_NoImports(t *testing.T) { + dir := t.TempDir() + writeFile(t, filepath.Join(dir, "yconverge.cue"), ` +package test +step: checks: [] +`) + deps, err := ParseImports(filepath.Join(dir, "yconverge.cue")) + if err != nil { + t.Fatal(err) + } + if len(deps) != 0 { + t.Fatalf("expected no deps, got %v", deps) + } +} + +func TestParseImports_MissingFile(t *testing.T) { + deps, err := ParseImports("/nonexistent/yconverge.cue") + if err != nil { + t.Fatal(err) + } + if deps != nil { + t.Fatalf("expected nil, got %v", deps) + } +} + +func TestFindCueFiles(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "a/yconverge.cue"), "package a\n") + writeFile(t, filepath.Join(root, "b/kustomization.yaml"), "") + writeFile(t, filepath.Join(root, "c/yconverge.cue"), "package c\n") + + dirs := []string{ + filepath.Join(root, "a"), + filepath.Join(root, "b"), + filepath.Join(root, "c"), + } + found := FindCueFiles(dirs) + if len(found) != 2 { + t.Fatalf("expected 2, got %v", found) + } + if found[0] != filepath.Join(root, "a") || found[1] != filepath.Join(root, "c") { + t.Fatalf("unexpected dirs: %v", found) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} From a50a483ee4de49a1ca93e41699abb2d35346e848 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Thu, 23 Apr 2026 18:57:00 +0000 Subject: [PATCH 08/43] feat: add dependency resolution and Run() orchestrator --- pkg/yconverge/deps.go | 101 +++++++++++++++ pkg/yconverge/deps_test.go | 215 ++++++++++++++++++++++++++++++++ pkg/yconverge/yconverge.go | 174 ++++++++++++++++++++++++++ pkg/yconverge/yconverge_test.go | 90 +++++++++++++ 4 files changed, 580 insertions(+) create mode 100644 pkg/yconverge/deps.go create mode 100644 pkg/yconverge/deps_test.go create mode 100644 pkg/yconverge/yconverge.go create mode 100644 pkg/yconverge/yconverge_test.go diff --git a/pkg/yconverge/deps.go b/pkg/yconverge/deps.go new file mode 100644 index 0000000..d2b1c5a --- /dev/null +++ b/pkg/yconverge/deps.go @@ -0,0 +1,101 @@ +package yconverge + +import ( + "fmt" + "path/filepath" + "strings" +) + +// ResolveDeps performs a topological sort of the dependency graph rooted +// at the given kustomize directory. It reads CUE imports from yconverge.cue +// files to discover dependencies. Returns the list of directories to +// converge, in dependency order (deps first, target last). +// +// baseDir is the root directory from which CUE import paths are resolved. +// For ystack, this is the ystack root (the CUE module root). +// targetDir is the kustomize base to converge. +func ResolveDeps(baseDir, targetDir string) ([]string, error) { + abs, err := filepath.Abs(targetDir) + if err != nil { + return nil, fmt.Errorf("resolve target: %w", err) + } + baseAbs, err := filepath.Abs(baseDir) + if err != nil { + return nil, fmt.Errorf("resolve base: %w", err) + } + + visited := make(map[string]bool) + var order []string + if err := resolveDepsWalk(baseAbs, abs, visited, &order); err != nil { + return nil, err + } + return order, nil +} + +func resolveDepsWalk(baseDir, dir string, visited map[string]bool, order *[]string) error { + if visited[dir] { + return nil + } + visited[dir] = true + + cueFile := filepath.Join(dir, "yconverge.cue") + imports, err := ParseImports(cueFile) + if err != nil { + return fmt.Errorf("parse imports %s: %w", cueFile, err) + } + + for _, imp := range imports { + depDir := filepath.Join(baseDir, imp) + depAbs, err := filepath.Abs(depDir) + if err != nil { + return fmt.Errorf("resolve dep %s: %w", imp, err) + } + if err := resolveDepsWalk(baseDir, depAbs, visited, order); err != nil { + return err + } + } + + *order = append(*order, dir) + return nil +} + +// FindCueModuleRoot walks up from dir looking for cue.mod/module.cue. +// Returns the directory containing cue.mod, or empty string if not found. +func FindCueModuleRoot(dir string) string { + abs, err := filepath.Abs(dir) + if err != nil { + return "" + } + for { + if fileExists(filepath.Join(abs, "cue.mod", "module.cue")) { + return abs + } + parent := filepath.Dir(abs) + if parent == abs { + return "" + } + abs = parent + } +} + +func fileExists(path string) bool { + _, err := filepath.Abs(path) + if err != nil { + return false + } + info, err := filepath.Glob(path) + if err != nil || len(info) == 0 { + return false + } + return true +} + +// RelPath returns path relative to base, or the original path if +// the relative path would require going above base. +func RelPath(base, path string) string { + rel, err := filepath.Rel(base, path) + if err != nil || strings.HasPrefix(rel, "..") { + return path + } + return rel +} diff --git a/pkg/yconverge/deps_test.go b/pkg/yconverge/deps_test.go new file mode 100644 index 0000000..db81fd0 --- /dev/null +++ b/pkg/yconverge/deps_test.go @@ -0,0 +1,215 @@ +package yconverge + +import ( + "path/filepath" + "testing" +) + +func TestResolveDeps_NoDeps(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "base/yconverge.cue"), ` +package base +step: checks: [] +`) + writeFile(t, filepath.Join(root, "base/kustomization.yaml"), "") + + order, err := ResolveDeps(root, filepath.Join(root, "base")) + if err != nil { + t.Fatal(err) + } + if len(order) != 1 { + t.Fatalf("expected 1 entry, got %v", order) + } +} + +func TestResolveDeps_LinearChain(t *testing.T) { + root := t.TempDir() + + // a depends on b, b depends on c + writeFile(t, filepath.Join(root, "c/yconverge.cue"), ` +package c +step: checks: [] +`) + writeFile(t, filepath.Join(root, "b/yconverge.cue"), ` +package b +import "yolean.se/ystack/c:c" +_dep: c.step +step: checks: [] +`) + writeFile(t, filepath.Join(root, "a/yconverge.cue"), ` +package a +import "yolean.se/ystack/b:b" +_dep: b.step +step: checks: [] +`) + + order, err := ResolveDeps(root, filepath.Join(root, "a")) + if err != nil { + t.Fatal(err) + } + if len(order) != 3 { + t.Fatalf("expected 3 entries, got %d: %v", len(order), order) + } + // c first, then b, then a + if filepath.Base(order[0]) != "c" { + t.Fatalf("expected c first, got %s", order[0]) + } + if filepath.Base(order[1]) != "b" { + t.Fatalf("expected b second, got %s", order[1]) + } + if filepath.Base(order[2]) != "a" { + t.Fatalf("expected a last, got %s", order[2]) + } +} + +func TestResolveDeps_DiamondDependency(t *testing.T) { + root := t.TempDir() + + // top depends on left and right, both depend on shared + writeFile(t, filepath.Join(root, "shared/yconverge.cue"), ` +package shared +step: checks: [] +`) + writeFile(t, filepath.Join(root, "left/yconverge.cue"), ` +package left +import "yolean.se/ystack/shared:shared" +_dep: shared.step +step: checks: [] +`) + writeFile(t, filepath.Join(root, "right/yconverge.cue"), ` +package right +import "yolean.se/ystack/shared:shared" +_dep: shared.step +step: checks: [] +`) + writeFile(t, filepath.Join(root, "top/yconverge.cue"), ` +package top +import ( + "yolean.se/ystack/left:left" + "yolean.se/ystack/right:right" +) +_dep_l: left.step +_dep_r: right.step +step: checks: [] +`) + + order, err := ResolveDeps(root, filepath.Join(root, "top")) + if err != nil { + t.Fatal(err) + } + if len(order) != 4 { + t.Fatalf("expected 4 entries, got %d: %v", len(order), order) + } + // shared must come before both left and right + sharedIdx := indexOf(order, "shared") + leftIdx := indexOf(order, "left") + rightIdx := indexOf(order, "right") + topIdx := indexOf(order, "top") + + if sharedIdx < 0 || leftIdx < 0 || rightIdx < 0 || topIdx < 0 { + t.Fatalf("missing entries: %v", order) + } + if sharedIdx >= leftIdx || sharedIdx >= rightIdx { + t.Fatalf("shared must come before left and right: %v", basenames(order)) + } + if topIdx != len(order)-1 { + t.Fatalf("top must be last: %v", basenames(order)) + } +} + +func TestResolveDeps_NoCueFile(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "base/kustomization.yaml"), "") + // No yconverge.cue — should return just the target + order, err := ResolveDeps(root, filepath.Join(root, "base")) + if err != nil { + t.Fatal(err) + } + if len(order) != 1 { + t.Fatalf("expected 1 entry (target only), got %v", order) + } +} + +func TestResolveDeps_VisitsEachOnce(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "shared/yconverge.cue"), ` +package shared +step: checks: [] +`) + // Both a and b depend on shared + writeFile(t, filepath.Join(root, "a/yconverge.cue"), ` +package a +import "yolean.se/ystack/shared:shared" +_dep: shared.step +step: checks: [] +`) + writeFile(t, filepath.Join(root, "b/yconverge.cue"), ` +package b +import "yolean.se/ystack/shared:shared" +_dep: shared.step +step: checks: [] +`) + // top depends on a and b + writeFile(t, filepath.Join(root, "top/yconverge.cue"), ` +package top +import ( + "yolean.se/ystack/a:a" + "yolean.se/ystack/b:b" +) +_dep_a: a.step +_dep_b: b.step +step: checks: [] +`) + + order, err := ResolveDeps(root, filepath.Join(root, "top")) + if err != nil { + t.Fatal(err) + } + // shared should appear exactly once + count := 0 + for _, d := range order { + if filepath.Base(d) == "shared" { + count++ + } + } + if count != 1 { + t.Fatalf("shared should appear once, got %d in %v", count, basenames(order)) + } +} + +func TestFindCueModuleRoot(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "cue.mod/module.cue"), `module: "test"`) + writeFile(t, filepath.Join(root, "deep/nested/dir/file.txt"), "") + + got := FindCueModuleRoot(filepath.Join(root, "deep/nested/dir")) + abs, _ := filepath.Abs(root) + if got != abs { + t.Fatalf("expected %s, got %s", abs, got) + } +} + +func TestFindCueModuleRoot_NotFound(t *testing.T) { + root := t.TempDir() + got := FindCueModuleRoot(root) + if got != "" { + t.Fatalf("expected empty, got %s", got) + } +} + +func indexOf(order []string, name string) int { + for i, d := range order { + if filepath.Base(d) == name { + return i + } + } + return -1 +} + +func basenames(paths []string) []string { + var names []string + for _, p := range paths { + names = append(names, filepath.Base(p)) + } + return names +} diff --git a/pkg/yconverge/yconverge.go b/pkg/yconverge/yconverge.go new file mode 100644 index 0000000..27cb962 --- /dev/null +++ b/pkg/yconverge/yconverge.go @@ -0,0 +1,174 @@ +package yconverge + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + + "go.uber.org/zap" + + "github.com/Yolean/y-cluster/pkg/kustomize/traverse" +) + +// Options configures a yconverge run. +type Options struct { + Context string // Kubernetes context name (required) + KustomizeDir string // path to kustomize base (required) + DryRun string // "server" or "" (empty = real apply) + ChecksOnly bool // skip apply, run checks only + PrintDeps bool // print dependency order and exit + SkipChecks bool // skip checks after apply +} + +// Result holds the outcome of a yconverge run. +type Result struct { + // Steps lists the directories that were converged, in order. + Steps []string +} + +// Run performs a full yconverge: resolve dependencies, apply each step +// (with kustomize server-side apply), and run checks. +func Run(ctx context.Context, opts Options, logger *zap.Logger) (*Result, error) { + if opts.Context == "" { + return nil, fmt.Errorf("--context is required") + } + if opts.KustomizeDir == "" { + return nil, fmt.Errorf("-k is required") + } + + absDir, err := filepath.Abs(opts.KustomizeDir) + if err != nil { + return nil, fmt.Errorf("resolve path: %w", err) + } + + // Find the CUE module root for resolving import paths + cueRoot := FindCueModuleRoot(absDir) + + // Resolve dependency order + var steps []string + if cueRoot != "" { + steps, err = ResolveDeps(cueRoot, absDir) + if err != nil { + return nil, fmt.Errorf("resolve deps: %w", err) + } + } else { + steps = []string{absDir} + } + + if opts.PrintDeps { + return &Result{Steps: steps}, nil + } + + // Multi-step: if more than one step, each step before the last + // is a dependency that gets its own full convergence cycle. + if len(steps) > 1 { + logger.Info("converge plan", + zap.String("context", opts.Context), + zap.Int("steps", len(steps)), + ) + for _, step := range steps[:len(steps)-1] { + logger.Info("converge dependency", + zap.String("dir", RelPath(cueRoot, step)), + ) + depOpts := Options{ + Context: opts.Context, + KustomizeDir: step, + DryRun: opts.DryRun, + SkipChecks: opts.SkipChecks, + } + if _, err := convergeSingle(ctx, depOpts, logger); err != nil { + return nil, fmt.Errorf("dependency %s: %w", RelPath(cueRoot, step), err) + } + } + } + + // Final step: the target itself + logger.Info("converge target", + zap.String("dir", RelPath(cueRoot, absDir)), + ) + if _, err := convergeSingle(ctx, opts, logger); err != nil { + return nil, err + } + + return &Result{Steps: steps}, nil +} + +// convergeSingle handles one apply+check cycle for a single kustomize base. +func convergeSingle(ctx context.Context, opts Options, logger *zap.Logger) (*Result, error) { + absDir, err := filepath.Abs(opts.KustomizeDir) + if err != nil { + return nil, err + } + + // Walk the kustomize tree to find yconverge.cue files and namespace + tResult, err := traverse.Walk(absDir, func(format string, a ...any) { + logger.Warn(fmt.Sprintf(format, a...)) + }) + if err != nil { + return nil, fmt.Errorf("traverse %s: %w", opts.KustomizeDir, err) + } + + namespace := tResult.Namespace + + // Find yconverge.cue files in the traversed directories + cueDirs := FindCueFiles(tResult.Dirs) + for _, d := range cueDirs { + logger.Debug("found yconverge.cue", zap.String("dir", d)) + } + + // Apply (unless checks-only) + if !opts.ChecksOnly { + if err := kubectlApply(ctx, opts, logger); err != nil { + return nil, fmt.Errorf("apply %s: %w", opts.KustomizeDir, err) + } + } + + // Run checks (unless skip-checks) + if !opts.SkipChecks { + runner := &CheckRunner{ + Context: opts.Context, + Namespace: namespace, + Logger: logger, + } + for _, cueDir := range cueDirs { + checks, err := ParseChecks(cueDir) + if err != nil { + return nil, fmt.Errorf("parse checks %s: %w", cueDir, err) + } + if len(checks) == 0 { + continue + } + if err := runner.RunAll(ctx, checks); err != nil { + return nil, fmt.Errorf("checks %s: %w", cueDir, err) + } + } + } + + return &Result{Steps: []string{absDir}}, nil +} + +// kubectlApply runs kubectl apply --server-side on a kustomize base. +func kubectlApply(ctx context.Context, opts Options, logger *zap.Logger) error { + args := []string{ + "--context=" + opts.Context, + "apply", + "--server-side=true", + "-k", opts.KustomizeDir, + } + if opts.DryRun != "" { + args = append(args, "--dry-run="+opts.DryRun) + } + + logger.Debug("kubectl apply", zap.Strings("args", args)) + + cmd := exec.CommandContext(ctx, "kubectl", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("kubectl apply: %s: %w", string(output), err) + } + if len(output) > 0 { + logger.Info("applied", zap.String("output", string(output))) + } + return nil +} diff --git a/pkg/yconverge/yconverge_test.go b/pkg/yconverge/yconverge_test.go new file mode 100644 index 0000000..97c9def --- /dev/null +++ b/pkg/yconverge/yconverge_test.go @@ -0,0 +1,90 @@ +package yconverge + +import ( + "context" + "path/filepath" + "testing" + + "go.uber.org/zap" +) + +func TestRun_PrintDeps(t *testing.T) { + root := t.TempDir() + + // Create a CUE module root matching the import prefix ParseImports expects + writeFile(t, filepath.Join(root, "cue.mod/module.cue"), `module: "yolean.se/ystack"`) + + // Base with no deps + writeFile(t, filepath.Join(root, "base/kustomization.yaml"), "") + writeFile(t, filepath.Join(root, "base/yconverge.cue"), ` +package base +step: checks: [] +`) + // Target depends on base + writeFile(t, filepath.Join(root, "target/kustomization.yaml"), "") + writeFile(t, filepath.Join(root, "target/yconverge.cue"), ` +package target +import "yolean.se/ystack/base:base" +_dep: base.step +step: checks: [] +`) + + logger, _ := zap.NewDevelopment() + result, err := Run(context.Background(), Options{ + Context: "test", + KustomizeDir: filepath.Join(root, "target"), + PrintDeps: true, + }, logger) + if err != nil { + t.Fatal(err) + } + if len(result.Steps) != 2 { + t.Fatalf("expected 2 steps, got %d: %v", len(result.Steps), result.Steps) + } + if filepath.Base(result.Steps[0]) != "base" { + t.Fatalf("expected base first, got %s", filepath.Base(result.Steps[0])) + } + if filepath.Base(result.Steps[1]) != "target" { + t.Fatalf("expected target last, got %s", filepath.Base(result.Steps[1])) + } +} + +func TestRun_NoCueModule(t *testing.T) { + root := t.TempDir() + // No cue.mod — should still work (single step, no dep resolution) + writeFile(t, filepath.Join(root, "base/kustomization.yaml"), "") + + logger, _ := zap.NewDevelopment() + result, err := Run(context.Background(), Options{ + Context: "test", + KustomizeDir: filepath.Join(root, "base"), + PrintDeps: true, + }, logger) + if err != nil { + t.Fatal(err) + } + if len(result.Steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(result.Steps)) + } +} + +func TestRun_MissingContext(t *testing.T) { + logger, _ := zap.NewDevelopment() + _, err := Run(context.Background(), Options{ + KustomizeDir: "/tmp", + }, logger) + if err == nil { + t.Fatal("expected error for missing context") + } +} + +func TestRun_MissingDir(t *testing.T) { + logger, _ := zap.NewDevelopment() + _, err := Run(context.Background(), Options{ + Context: "test", + KustomizeDir: "", + }, logger) + if err == nil { + t.Fatal("expected error for missing dir") + } +} From b0d44e14ab6bdcc87c1242f9dd384fcce53973d0 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Thu, 23 Apr 2026 19:31:47 +0000 Subject: [PATCH 09/43] feat: add cobra CLI, validate against real cluster --- cmd/y-cluster/main.go | 129 +++++++++++++++++++++++++++++++++++++ cmd/y-cluster/main_test.go | 105 ++++++++++++++++++++++++++++++ go.mod | 3 + go.sum | 9 +++ pkg/yconverge/yconverge.go | 1 + 5 files changed, 247 insertions(+) create mode 100644 cmd/y-cluster/main.go create mode 100644 cmd/y-cluster/main_test.go diff --git a/cmd/y-cluster/main.go b/cmd/y-cluster/main.go new file mode 100644 index 0000000..e2cade9 --- /dev/null +++ b/cmd/y-cluster/main.go @@ -0,0 +1,129 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "syscall" + + "github.com/spf13/cobra" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "github.com/Yolean/y-cluster/pkg/yconverge" +) + +var version = "dev" + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + if err := rootCmd().ExecuteContext(ctx); err != nil { + os.Exit(1) + } +} + +func rootCmd() *cobra.Command { + var verbose bool + + root := &cobra.Command{ + Use: binaryName(), + Short: "Idempotent Kubernetes convergence with dependency ordering and checks", + Version: version, + PersistentPreRun: func(cmd *cobra.Command, args []string) { + logger := newLogger(verbose) + cmd.SetContext(withLogger(cmd.Context(), logger)) + }, + } + + root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "debug logging") + + root.AddCommand(yconvergeCmd()) + + return root +} + +func yconvergeCmd() *cobra.Command { + var opts yconverge.Options + + cmd := &cobra.Command{ + Use: "yconverge", + Short: "Apply a kustomize base with dependency resolution and checks", + RunE: func(cmd *cobra.Command, args []string) error { + logger := loggerFromContext(cmd.Context()) + result, err := yconverge.Run(cmd.Context(), opts, logger) + if err != nil { + logger.Error("convergence failed", zap.Error(err)) + return err + } + if opts.PrintDeps { + for _, step := range result.Steps { + fmt.Fprintln(cmd.OutOrStdout(), step) + } + } + return nil + }, + } + + cmd.Flags().StringVar(&opts.Context, "context", "", "Kubernetes context name (required)") + cmd.Flags().StringVarP(&opts.KustomizeDir, "kustomize-dir", "k", "", "path to kustomize base (required)") + cmd.Flags().StringVar(&opts.DryRun, "dry-run", "", "dry-run mode (server|none)") + cmd.Flags().BoolVar(&opts.ChecksOnly, "checks-only", false, "run checks without applying") + cmd.Flags().BoolVar(&opts.PrintDeps, "print-deps", false, "print dependency order and exit") + cmd.Flags().BoolVar(&opts.SkipChecks, "skip-checks", false, "skip checks after apply") + + if err := cmd.MarkFlagRequired("context"); err != nil { + panic(err) + } + if err := cmd.MarkFlagRequired("kustomize-dir"); err != nil { + panic(err) + } + + return cmd +} + +// binaryName returns "y-cluster" or "kubectl-yconverge" depending on +// how the binary was invoked, supporting kubectl plugin symlinks. +func binaryName() string { + name := filepath.Base(os.Args[0]) + if strings.HasPrefix(name, "kubectl-") { + return name + } + return "y-cluster" +} + +func newLogger(verbose bool) *zap.Logger { + level := zapcore.InfoLevel + if verbose { + level = zapcore.DebugLevel + } + cfg := zap.Config{ + Level: zap.NewAtomicLevelAt(level), + Encoding: "console", + EncoderConfig: zap.NewDevelopmentEncoderConfig(), + OutputPaths: []string{"stderr"}, + ErrorOutputPaths: []string{"stderr"}, + } + logger, err := cfg.Build() + if err != nil { + panic(err) + } + return logger +} + +type loggerKey struct{} + +func withLogger(ctx context.Context, logger *zap.Logger) context.Context { + return context.WithValue(ctx, loggerKey{}, logger) +} + +func loggerFromContext(ctx context.Context) *zap.Logger { + if l, ok := ctx.Value(loggerKey{}).(*zap.Logger); ok { + return l + } + return zap.NewNop() +} diff --git a/cmd/y-cluster/main_test.go b/cmd/y-cluster/main_test.go new file mode 100644 index 0000000..333e59f --- /dev/null +++ b/cmd/y-cluster/main_test.go @@ -0,0 +1,105 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeFile(t *testing.T, path, content string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestYconvergeCmd_PrintDeps(t *testing.T) { + root := t.TempDir() + writeFile(t, filepath.Join(root, "cue.mod/module.cue"), `module: "yolean.se/ystack"`) + writeFile(t, filepath.Join(root, "base/kustomization.yaml"), "") + writeFile(t, filepath.Join(root, "base/yconverge.cue"), ` +package base +step: checks: [] +`) + writeFile(t, filepath.Join(root, "target/kustomization.yaml"), "") + writeFile(t, filepath.Join(root, "target/yconverge.cue"), ` +package target +import "yolean.se/ystack/base:base" +_dep: base.step +step: checks: [] +`) + + cmd := rootCmd() + var out strings.Builder + cmd.SetOut(&out) + cmd.SetArgs([]string{ + "yconverge", + "--context=test", + "-k", filepath.Join(root, "target"), + "--print-deps", + }) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d: %v", len(lines), lines) + } + if !strings.HasSuffix(lines[0], "/base") { + t.Fatalf("expected base first, got %s", lines[0]) + } + if !strings.HasSuffix(lines[1], "/target") { + t.Fatalf("expected target last, got %s", lines[1]) + } +} + +func TestYconvergeCmd_MissingContext(t *testing.T) { + cmd := rootCmd() + cmd.SetArgs([]string{"yconverge", "-k", "/tmp"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for missing --context") + } +} + +func TestYconvergeCmd_MissingK(t *testing.T) { + cmd := rootCmd() + cmd.SetArgs([]string{"yconverge", "--context=test"}) + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for missing -k") + } +} + +func TestRootCmd_Version(t *testing.T) { + cmd := rootCmd() + var out strings.Builder + cmd.SetOut(&out) + cmd.SetArgs([]string{"--version"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "dev") { + t.Fatalf("expected version 'dev', got %s", out.String()) + } +} + +func TestBinaryName_Default(t *testing.T) { + // Save and restore os.Args[0] + orig := os.Args[0] + defer func() { os.Args[0] = orig }() + + os.Args[0] = "/usr/local/bin/y-cluster" + if got := binaryName(); got != "y-cluster" { + t.Fatalf("expected y-cluster, got %s", got) + } + + os.Args[0] = "/usr/local/bin/kubectl-yconverge" + if got := binaryName(); got != "kubectl-yconverge" { + t.Fatalf("expected kubectl-yconverge, got %s", got) + } +} diff --git a/go.mod b/go.mod index bf74e10..3fcde3a 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.26.1 require ( cuelang.org/go v0.16.1 + github.com/spf13/cobra v1.10.2 go.uber.org/zap v1.27.1 sigs.k8s.io/kustomize/api v0.21.1 sigs.k8s.io/yaml v1.5.0 @@ -20,6 +21,7 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/go-wordwrap v1.0.1 // indirect @@ -28,6 +30,7 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/spf13/pflag v1.0.10 // indirect go.uber.org/multierr v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect diff --git a/go.sum b/go.sum index 15e9ce6..eb5bf6c 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,7 @@ cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE= cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM= github.com/cockroachdb/apd/v3 v3.2.1 h1:U+8j7t0axsIgvQUqthuNm82HIrYXodOV2iWLWtEaIwg= github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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= @@ -30,6 +31,8 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= @@ -59,6 +62,12 @@ github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 h1:2PC6Ql github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= 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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/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= diff --git a/pkg/yconverge/yconverge.go b/pkg/yconverge/yconverge.go index 27cb962..70397e3 100644 --- a/pkg/yconverge/yconverge.go +++ b/pkg/yconverge/yconverge.go @@ -154,6 +154,7 @@ func kubectlApply(ctx context.Context, opts Options, logger *zap.Logger) error { "--context=" + opts.Context, "apply", "--server-side=true", + "--force-conflicts", "-k", opts.KustomizeDir, } if opts.DryRun != "" { From aba992f52ef3c25be77f51808eab8c7a99e34d2d Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Thu, 23 Apr 2026 19:57:27 +0000 Subject: [PATCH 10/43] feat: support kubectl-yconverge plugin invocation --- cmd/y-cluster/main.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/cmd/y-cluster/main.go b/cmd/y-cluster/main.go index e2cade9..52ed44c 100644 --- a/cmd/y-cluster/main.go +++ b/cmd/y-cluster/main.go @@ -22,7 +22,14 @@ func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() - if err := rootCmd().ExecuteContext(ctx); err != nil { + cmd := rootCmd() + + // When invoked as kubectl-yconverge, act as the yconverge subcommand directly + if strings.HasPrefix(filepath.Base(os.Args[0]), "kubectl-yconverge") { + cmd = yconvergePluginCmd() + } + + if err := cmd.ExecuteContext(ctx); err != nil { os.Exit(1) } } @@ -47,6 +54,20 @@ func rootCmd() *cobra.Command { return root } +func yconvergePluginCmd() *cobra.Command { + cmd := yconvergeCmd() + cmd.Use = "kubectl-yconverge" + cmd.Short = "kubectl plugin: apply a kustomize base with dependency resolution and checks" + // Add persistent flags that rootCmd normally provides + var verbose bool + cmd.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "debug logging") + cmd.PersistentPreRun = func(c *cobra.Command, args []string) { + logger := newLogger(verbose) + c.SetContext(withLogger(c.Context(), logger)) + } + return cmd +} + func yconvergeCmd() *cobra.Command { var opts yconverge.Options From 8fe8e825e0074198b4cb529f916f388143c71da5 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 04:54:32 +0000 Subject: [PATCH 11/43] test: add unit tests for ParseChecks --- pkg/yconverge/cue_test.go | 247 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) diff --git a/pkg/yconverge/cue_test.go b/pkg/yconverge/cue_test.go index 2d6b4e8..a1e29ed 100644 --- a/pkg/yconverge/cue_test.go +++ b/pkg/yconverge/cue_test.go @@ -109,6 +109,253 @@ func TestFindCueFiles(t *testing.T) { } } +// setupCueModule creates a temp directory with a CUE module and the +// vendored verify schema, so ParseChecks can resolve imports. +func setupCueModule(t *testing.T) string { + t.Helper() + root := t.TempDir() + writeFile(t, filepath.Join(root, "cue.mod/module.cue"), `module: "test.local" +language: version: "v0.16.0" +`) + writeFile(t, filepath.Join(root, "cue.mod/pkg/yolean.se/ystack/yconverge/verify/schema.cue"), `package verify + +#Step: { + checks: [...#Check] +} + +#Check: #Wait | #Rollout | #Exec + +#Wait: { + kind: "wait" + resource: string + for: string + namespace?: string + timeout: *"60s" | string + description: *"" | string +} + +#Rollout: { + kind: "rollout" + resource: string + namespace?: string + timeout: *"60s" | string + description: *"" | string +} + +#Exec: { + kind: "exec" + command: string + timeout: *"60s" | string + description: string +} +`) + return root +} + +func TestParseChecks_RolloutCheck(t *testing.T) { + root := setupCueModule(t) + writeFile(t, filepath.Join(root, "base/yconverge.cue"), `package base + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [{ + kind: "rollout" + resource: "deployment/my-app" + timeout: "120s" + }] +} +`) + checks, err := ParseChecks(filepath.Join(root, "base")) + if err != nil { + t.Fatal(err) + } + if len(checks) != 1 { + t.Fatalf("expected 1 check, got %d", len(checks)) + } + if checks[0].Kind != "rollout" { + t.Fatalf("expected rollout, got %s", checks[0].Kind) + } + if checks[0].Resource != "deployment/my-app" { + t.Fatalf("expected deployment/my-app, got %s", checks[0].Resource) + } + if checks[0].Timeout != "120s" { + t.Fatalf("expected 120s, got %s", checks[0].Timeout) + } +} + +func TestParseChecks_ExecCheck(t *testing.T) { + root := setupCueModule(t) + writeFile(t, filepath.Join(root, "base/yconverge.cue"), `package base + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [{ + kind: "exec" + command: "curl -sf http://$NAMESPACE.example.com/" + timeout: "60s" + description: "site responds" + }] +} +`) + checks, err := ParseChecks(filepath.Join(root, "base")) + if err != nil { + t.Fatal(err) + } + if len(checks) != 1 { + t.Fatalf("expected 1 check, got %d", len(checks)) + } + if checks[0].Kind != "exec" { + t.Fatalf("expected exec, got %s", checks[0].Kind) + } + if checks[0].Command != "curl -sf http://$NAMESPACE.example.com/" { + t.Fatalf("unexpected command: %s", checks[0].Command) + } + if checks[0].Description != "site responds" { + t.Fatalf("unexpected description: %s", checks[0].Description) + } +} + +func TestParseChecks_WaitCheck(t *testing.T) { + root := setupCueModule(t) + writeFile(t, filepath.Join(root, "base/yconverge.cue"), `package base + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [{ + kind: "wait" + resource: "ns/dev" + for: "jsonpath={.status.phase}=Active" + timeout: "30s" + description: "namespace active" + }] +} +`) + checks, err := ParseChecks(filepath.Join(root, "base")) + if err != nil { + t.Fatal(err) + } + if len(checks) != 1 { + t.Fatalf("expected 1 check, got %d", len(checks)) + } + if checks[0].Kind != "wait" { + t.Fatalf("expected wait, got %s", checks[0].Kind) + } + if checks[0].For != "jsonpath={.status.phase}=Active" { + t.Fatalf("unexpected for: %s", checks[0].For) + } +} + +func TestParseChecks_MultipleChecks(t *testing.T) { + root := setupCueModule(t) + writeFile(t, filepath.Join(root, "base/yconverge.cue"), `package base + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [ + { + kind: "rollout" + resource: "deployment/app" + }, + { + kind: "exec" + command: "true" + description: "always passes" + }, + ] +} +`) + checks, err := ParseChecks(filepath.Join(root, "base")) + if err != nil { + t.Fatal(err) + } + if len(checks) != 2 { + t.Fatalf("expected 2 checks, got %d", len(checks)) + } + if checks[0].Kind != "rollout" || checks[1].Kind != "exec" { + t.Fatalf("unexpected kinds: %s, %s", checks[0].Kind, checks[1].Kind) + } +} + +func TestParseChecks_EmptyChecks(t *testing.T) { + root := setupCueModule(t) + writeFile(t, filepath.Join(root, "base/yconverge.cue"), `package base + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [] +} +`) + checks, err := ParseChecks(filepath.Join(root, "base")) + if err != nil { + t.Fatal(err) + } + if len(checks) != 0 { + t.Fatalf("expected 0 checks, got %d", len(checks)) + } +} + +func TestParseChecks_DefaultTimeout(t *testing.T) { + root := setupCueModule(t) + writeFile(t, filepath.Join(root, "base/yconverge.cue"), `package base + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [{ + kind: "rollout" + resource: "deployment/app" + }] +} +`) + checks, err := ParseChecks(filepath.Join(root, "base")) + if err != nil { + t.Fatal(err) + } + if checks[0].Timeout != "60s" { + t.Fatalf("expected default 60s, got %s", checks[0].Timeout) + } +} + +func TestParseChecks_WithNamespace(t *testing.T) { + root := setupCueModule(t) + writeFile(t, filepath.Join(root, "base/yconverge.cue"), `package base + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [{ + kind: "rollout" + resource: "deployment/registry" + namespace: "ystack" + timeout: "120s" + }] +} +`) + checks, err := ParseChecks(filepath.Join(root, "base")) + if err != nil { + t.Fatal(err) + } + if checks[0].Namespace != "ystack" { + t.Fatalf("expected namespace ystack, got %s", checks[0].Namespace) + } +} + +func TestParseChecks_NoCueFile(t *testing.T) { + root := setupCueModule(t) + if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil { + t.Fatal(err) + } + _, err := ParseChecks(filepath.Join(root, "empty")) + if err == nil { + t.Fatal("expected error for dir with no CUE files") + } +} + func equalStrings(a, b []string) bool { if len(a) != len(b) { return false From 1fab600e7f82f173f5434f617928757fe723f764 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 04:59:37 +0000 Subject: [PATCH 12/43] docs: add TESTING.md for maintainers From 5db1df77e5e993995b910f8ccc2715de12a40487 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 05:26:36 +0000 Subject: [PATCH 13/43] docs: provider detection, coverage tracking, remote runtime constraints - Graceful skipping: build tags gate compilation, runtime checks gate execution (no QEMU failures on Mac) - Coverage matrix: track which provider was tested per CI run - Remote runtime rule: never mount local dirs, never use local Docker socket for node containerd. Images go through registry or piped over SSH/exec. Matches production behavior. - Multipass provider added to test matrix Co-Authored-By: Claude Opus 4.6 (1M context) From 2cffaf26b37565e49285aca7a462f5d3ee798a7e Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 05:29:15 +0000 Subject: [PATCH 14/43] docs: use testcontainers-go for e2e test harness Abstracts the local container runtime (Docker, Podman, Colima) for creating kwok and k3s-in-Docker test clusters. No direct Docker client dependency in test code. Co-Authored-By: Claude Opus 4.6 (1M context) From 1892a6fa070fc3a5b90a5a631b6c57c07e3f13f0 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 05:34:41 +0000 Subject: [PATCH 15/43] docs: replace testcontainers-go with Moby client for e2e harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit e2e tests use github.com/moby/moby/client directly — the same API as the k3s-in-Docker provisioner. No separate test abstraction. Podman works via Docker-compatible socket. Co-Authored-By: Claude Opus 4.6 (1M context) From e2d9ef4d090848ee0d0f2192dd530900c10962b2 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 05:41:43 +0000 Subject: [PATCH 16/43] test: add yconverge e2e tests with kwok cluster MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 7 e2e tests using a kwok cluster in Docker: - Namespace: basic apply + wait check - Idempotent: re-apply succeeds - DependencyOrdering: transitive chain (namespace→configmap→dependent) - IndirectChecks: checks aggregated from base via traversal - NamespaceEnvVar: $NAMESPACE exported to exec checks - PrintDeps: dependency resolution without cluster - ChecksOnly: verify without re-applying Self-contained testdata/ with CUE module, verify schema, and 5 test bases. No ystack or checkit dependency. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/yconverge_test.go | 287 ++++++++++++++++++ testdata/cue.mod/module.cue | 2 + .../ystack/yconverge/verify/schema.cue | 44 +++ testdata/e2e-configmap/configmap.yaml | 6 + testdata/e2e-configmap/kustomization.yaml | 5 + testdata/e2e-configmap/yconverge.cue | 17 ++ testdata/e2e-dependency/configmap.yaml | 6 + testdata/e2e-dependency/kustomization.yaml | 5 + testdata/e2e-dependency/yconverge.cue | 17 ++ testdata/e2e-indirect/kustomization.yaml | 4 + testdata/e2e-namespace-check/configmap.yaml | 6 + .../e2e-namespace-check/kustomization.yaml | 5 + testdata/e2e-namespace-check/yconverge.cue | 12 + testdata/e2e-namespace/kustomization.yaml | 4 + testdata/e2e-namespace/namespace.yaml | 4 + testdata/e2e-namespace/yconverge.cue | 13 + 16 files changed, 437 insertions(+) create mode 100644 e2e/yconverge_test.go create mode 100644 testdata/cue.mod/module.cue create mode 100644 testdata/cue.mod/pkg/yolean.se/ystack/yconverge/verify/schema.cue create mode 100644 testdata/e2e-configmap/configmap.yaml create mode 100644 testdata/e2e-configmap/kustomization.yaml create mode 100644 testdata/e2e-configmap/yconverge.cue create mode 100644 testdata/e2e-dependency/configmap.yaml create mode 100644 testdata/e2e-dependency/kustomization.yaml create mode 100644 testdata/e2e-dependency/yconverge.cue create mode 100644 testdata/e2e-indirect/kustomization.yaml create mode 100644 testdata/e2e-namespace-check/configmap.yaml create mode 100644 testdata/e2e-namespace-check/kustomization.yaml create mode 100644 testdata/e2e-namespace-check/yconverge.cue create mode 100644 testdata/e2e-namespace/kustomization.yaml create mode 100644 testdata/e2e-namespace/namespace.yaml create mode 100644 testdata/e2e-namespace/yconverge.cue diff --git a/e2e/yconverge_test.go b/e2e/yconverge_test.go new file mode 100644 index 0000000..502a10c --- /dev/null +++ b/e2e/yconverge_test.go @@ -0,0 +1,287 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/Yolean/y-cluster/pkg/yconverge" +) + +const ( + kwokImage = "registry.k8s.io/kwok/cluster:v0.7.0-k8s.v1.33.0" + containerName = "y-cluster-e2e" + contextName = "y-cluster-e2e" +) + +// testdataDir returns the absolute path to testdata/. +func testdataDir(t *testing.T) string { + t.Helper() + abs, err := filepath.Abs("../testdata") + if err != nil { + t.Fatal(err) + } + return abs +} + +// setupCluster creates a kwok cluster in Docker and returns a cleanup function. +// Writes a kubeconfig to a temp file and returns its path. +func setupCluster(t *testing.T) string { + t.Helper() + ctx := context.Background() + + // Check Docker is available + if err := exec.CommandContext(ctx, "docker", "info").Run(); err != nil { + t.Skip("Docker not available") + } + + // Remove any leftover container + _ = exec.CommandContext(ctx, "docker", "rm", "-f", containerName).Run() + + // Start kwok + cmd := exec.CommandContext(ctx, "docker", "run", "-d", + "--name", containerName, + "-p", "0:8080", + kwokImage) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("failed to start kwok: %s: %v", out, err) + } + + t.Cleanup(func() { + _ = exec.Command("docker", "rm", "-f", containerName).Run() + }) + + // Get mapped port + portOut, err := exec.CommandContext(ctx, "docker", "port", containerName, "8080").Output() + if err != nil { + t.Fatalf("failed to get port: %v", err) + } + hostPort := strings.TrimSpace(string(portOut)) + // docker port returns "0.0.0.0:12345" or "[::]:12345" + parts := strings.Split(hostPort, ":") + port := parts[len(parts)-1] + + // Write kubeconfig + kubeconfig := filepath.Join(t.TempDir(), "kubeconfig") + kubeconfigContent := fmt.Sprintf(`apiVersion: v1 +kind: Config +clusters: +- cluster: + server: http://127.0.0.1:%s + name: %s +contexts: +- context: + cluster: %s + user: %s + name: %s +current-context: %s +users: +- name: %s +`, port, contextName, contextName, contextName, contextName, contextName, contextName) + + if err := os.WriteFile(kubeconfig, []byte(kubeconfigContent), 0o600); err != nil { + t.Fatal(err) + } + os.Setenv("KUBECONFIG", kubeconfig) + t.Cleanup(func() { os.Unsetenv("KUBECONFIG") }) + + // Wait for API server + deadline := time.Now().Add(30 * time.Second) + for { + cmd := exec.CommandContext(ctx, "kubectl", "--context="+contextName, "get", "ns") + cmd.Env = append(os.Environ(), "KUBECONFIG="+kubeconfig) + if err := cmd.Run(); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("kwok cluster not ready after 30s") + } + time.Sleep(500 * time.Millisecond) + } + + return kubeconfig +} + +func logger(t *testing.T) *zap.Logger { + t.Helper() + l, err := zap.NewDevelopment() + if err != nil { + t.Fatal(err) + } + return l +} + +func TestYconverge_Namespace(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + + result, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-namespace"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } + if len(result.Steps) != 1 { + t.Fatalf("expected 1 step, got %d", len(result.Steps)) + } +} + +func TestYconverge_Idempotent(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + log := logger(t) + + // First apply + _, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-namespace"), + }, log) + if err != nil { + t.Fatal(err) + } + + // Second apply — must succeed (idempotent) + _, err = yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-namespace"), + }, log) + if err != nil { + t.Fatalf("idempotent re-apply failed: %v", err) + } +} + +func TestYconverge_DependencyOrdering(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + + // e2e-dependency → e2e-configmap → e2e-namespace (transitive) + result, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-dependency"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } + if len(result.Steps) != 3 { + t.Fatalf("expected 3 steps (namespace→configmap→dependency), got %d: %v", + len(result.Steps), basenames(result.Steps)) + } + if filepath.Base(result.Steps[0]) != "e2e-namespace" { + t.Fatalf("expected e2e-namespace first, got %s", filepath.Base(result.Steps[0])) + } + if filepath.Base(result.Steps[1]) != "e2e-configmap" { + t.Fatalf("expected e2e-configmap second, got %s", filepath.Base(result.Steps[1])) + } + if filepath.Base(result.Steps[2]) != "e2e-dependency" { + t.Fatalf("expected e2e-dependency last, got %s", filepath.Base(result.Steps[2])) + } +} + +func TestYconverge_IndirectChecks(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + + // e2e-namespace must exist first (e2e-indirect → e2e-configmap needs it) + _, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-namespace"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } + + // e2e-indirect has no yconverge.cue — checks come from e2e-configmap base + _, err = yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-indirect"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } +} + +func TestYconverge_NamespaceEnvVar(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + + // Create the namespace first + _, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-namespace"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } + + // The check verifies $NAMESPACE = "y-cluster-e2e" + _, err = yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-namespace-check"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } +} + +func TestYconverge_PrintDeps(t *testing.T) { + // No cluster needed for print-deps + td := testdataDir(t) + + result, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: "unused", + KustomizeDir: filepath.Join(td, "e2e-dependency"), + PrintDeps: true, + }, logger(t)) + if err != nil { + t.Fatal(err) + } + names := basenames(result.Steps) + if len(names) != 3 { + t.Fatalf("expected 3 deps, got %v", names) + } + if names[0] != "e2e-namespace" || names[1] != "e2e-configmap" || names[2] != "e2e-dependency" { + t.Fatalf("unexpected order: %v", names) + } +} + +func TestYconverge_ChecksOnly(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + + // Apply namespace first + _, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-namespace"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } + + // Checks-only: verify without re-applying + _, err = yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-namespace"), + ChecksOnly: true, + }, logger(t)) + if err != nil { + t.Fatal(err) + } +} + +func basenames(paths []string) []string { + var names []string + for _, p := range paths { + names = append(names, filepath.Base(p)) + } + return names +} diff --git a/testdata/cue.mod/module.cue b/testdata/cue.mod/module.cue new file mode 100644 index 0000000..e4f1c49 --- /dev/null +++ b/testdata/cue.mod/module.cue @@ -0,0 +1,2 @@ +module: "yolean.se/ystack" +language: version: "v0.16.0" diff --git a/testdata/cue.mod/pkg/yolean.se/ystack/yconverge/verify/schema.cue b/testdata/cue.mod/pkg/yolean.se/ystack/yconverge/verify/schema.cue new file mode 100644 index 0000000..2005544 --- /dev/null +++ b/testdata/cue.mod/pkg/yolean.se/ystack/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 +} diff --git a/testdata/e2e-configmap/configmap.yaml b/testdata/e2e-configmap/configmap.yaml new file mode 100644 index 0000000..b494f75 --- /dev/null +++ b/testdata/e2e-configmap/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: e2e-config +data: + key: value diff --git a/testdata/e2e-configmap/kustomization.yaml b/testdata/e2e-configmap/kustomization.yaml new file mode 100644 index 0000000..8729c53 --- /dev/null +++ b/testdata/e2e-configmap/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: y-cluster-e2e +resources: +- configmap.yaml diff --git a/testdata/e2e-configmap/yconverge.cue b/testdata/e2e-configmap/yconverge.cue new file mode 100644 index 0000000..8c3e69e --- /dev/null +++ b/testdata/e2e-configmap/yconverge.cue @@ -0,0 +1,17 @@ +package e2e_configmap + +import ( + "yolean.se/ystack/yconverge/verify" + "yolean.se/ystack/e2e-namespace:e2e_namespace" +) + +_dep_namespace: e2e_namespace.step + +step: verify.#Step & { + checks: [{ + kind: "exec" + command: "kubectl --context=$CONTEXT -n $NAMESPACE get configmap e2e-config" + timeout: "10s" + description: "configmap exists" + }] +} diff --git a/testdata/e2e-dependency/configmap.yaml b/testdata/e2e-dependency/configmap.yaml new file mode 100644 index 0000000..27fe773 --- /dev/null +++ b/testdata/e2e-dependency/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: e2e-dependent +data: + depends-on: e2e-config diff --git a/testdata/e2e-dependency/kustomization.yaml b/testdata/e2e-dependency/kustomization.yaml new file mode 100644 index 0000000..8729c53 --- /dev/null +++ b/testdata/e2e-dependency/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: y-cluster-e2e +resources: +- configmap.yaml diff --git a/testdata/e2e-dependency/yconverge.cue b/testdata/e2e-dependency/yconverge.cue new file mode 100644 index 0000000..a747ba3 --- /dev/null +++ b/testdata/e2e-dependency/yconverge.cue @@ -0,0 +1,17 @@ +package e2e_dependency + +import ( + "yolean.se/ystack/yconverge/verify" + "yolean.se/ystack/e2e-configmap:e2e_configmap" +) + +_dep_configmap: e2e_configmap.step + +step: verify.#Step & { + checks: [{ + kind: "exec" + command: "kubectl --context=$CONTEXT -n $NAMESPACE get configmap e2e-dependent" + timeout: "10s" + description: "dependent configmap exists" + }] +} diff --git a/testdata/e2e-indirect/kustomization.yaml b/testdata/e2e-indirect/kustomization.yaml new file mode 100644 index 0000000..8a70d54 --- /dev/null +++ b/testdata/e2e-indirect/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../e2e-configmap diff --git a/testdata/e2e-namespace-check/configmap.yaml b/testdata/e2e-namespace-check/configmap.yaml new file mode 100644 index 0000000..fe2b23a --- /dev/null +++ b/testdata/e2e-namespace-check/configmap.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: e2e-ns-check +data: + key: value diff --git a/testdata/e2e-namespace-check/kustomization.yaml b/testdata/e2e-namespace-check/kustomization.yaml new file mode 100644 index 0000000..8729c53 --- /dev/null +++ b/testdata/e2e-namespace-check/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: y-cluster-e2e +resources: +- configmap.yaml diff --git a/testdata/e2e-namespace-check/yconverge.cue b/testdata/e2e-namespace-check/yconverge.cue new file mode 100644 index 0000000..e1dd9e7 --- /dev/null +++ b/testdata/e2e-namespace-check/yconverge.cue @@ -0,0 +1,12 @@ +package e2e_namespace_check + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [{ + kind: "exec" + command: "test \"$NAMESPACE\" = \"y-cluster-e2e\"" + timeout: "5s" + description: "NAMESPACE env var is set correctly" + }] +} diff --git a/testdata/e2e-namespace/kustomization.yaml b/testdata/e2e-namespace/kustomization.yaml new file mode 100644 index 0000000..c313b54 --- /dev/null +++ b/testdata/e2e-namespace/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- namespace.yaml diff --git a/testdata/e2e-namespace/namespace.yaml b/testdata/e2e-namespace/namespace.yaml new file mode 100644 index 0000000..99159ef --- /dev/null +++ b/testdata/e2e-namespace/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: y-cluster-e2e diff --git a/testdata/e2e-namespace/yconverge.cue b/testdata/e2e-namespace/yconverge.cue new file mode 100644 index 0000000..18b91b5 --- /dev/null +++ b/testdata/e2e-namespace/yconverge.cue @@ -0,0 +1,13 @@ +package e2e_namespace + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [{ + kind: "wait" + resource: "ns/y-cluster-e2e" + for: "jsonpath={.status.phase}=Active" + timeout: "10s" + description: "namespace is active" + }] +} From bbccf839e3010f7c4ff8ac26ef3f7eafc66b0441 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 05:59:32 +0000 Subject: [PATCH 17/43] test: rewrite e2e with db/backend/frontend model, fix overlay dep resolution Replace namespace-based test bases with a three-tier application model: db (foundation), backend (depends on db), frontend (depends on backend). Each tier has base/ and optionally qa/ overlay. Tests cover: - CUE ordering: db before backend before frontend - Customization: qa overlay aggregates checks from base - Overlay deps: qa inherits base's CUE dependencies via traversal Fix Run() to resolve dependencies from all CUE files in the kustomize tree, not just the target dir. An overlay (backend/qa) that wraps a base (backend/base) now inherits the base's CUE dependencies (db). Add namespace caution note to README. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 9 + e2e/yconverge_test.go | 273 +++++++++--------- pkg/yconverge/deps.go | 9 + pkg/yconverge/yconverge.go | 29 +- testdata/e2e-backend/base/backend-config.yaml | 7 + .../e2e-backend/base/backend-service.yaml | 10 + .../base}/kustomization.yaml | 4 +- testdata/e2e-backend/base/yconverge.cue | 17 ++ testdata/e2e-backend/qa/kustomization.yaml | 16 + testdata/e2e-configmap/configmap.yaml | 6 - testdata/e2e-configmap/yconverge.cue | 17 -- testdata/e2e-db/base/db-config.yaml | 7 + testdata/e2e-db/base/db-service.yaml | 10 + .../base}/kustomization.yaml | 3 +- testdata/e2e-db/base/yconverge.cue | 12 + .../qa}/kustomization.yaml | 7 +- testdata/e2e-dependency/configmap.yaml | 6 - testdata/e2e-dependency/yconverge.cue | 17 -- .../e2e-frontend/base/frontend-config.yaml | 6 + .../base}/kustomization.yaml | 2 +- testdata/e2e-frontend/base/yconverge.cue | 17 ++ testdata/e2e-namespace-check/configmap.yaml | 6 - .../e2e-namespace-check/kustomization.yaml | 5 - testdata/e2e-namespace-check/yconverge.cue | 12 - testdata/e2e-namespace/namespace.yaml | 4 - testdata/e2e-namespace/yconverge.cue | 13 - 26 files changed, 294 insertions(+), 230 deletions(-) create mode 100644 testdata/e2e-backend/base/backend-config.yaml create mode 100644 testdata/e2e-backend/base/backend-service.yaml rename testdata/{e2e-dependency => e2e-backend/base}/kustomization.yaml (62%) create mode 100644 testdata/e2e-backend/base/yconverge.cue create mode 100644 testdata/e2e-backend/qa/kustomization.yaml delete mode 100644 testdata/e2e-configmap/configmap.yaml delete mode 100644 testdata/e2e-configmap/yconverge.cue create mode 100644 testdata/e2e-db/base/db-config.yaml create mode 100644 testdata/e2e-db/base/db-service.yaml rename testdata/{e2e-indirect => e2e-db/base}/kustomization.yaml (68%) create mode 100644 testdata/e2e-db/base/yconverge.cue rename testdata/{e2e-configmap => e2e-db/qa}/kustomization.yaml (53%) delete mode 100644 testdata/e2e-dependency/configmap.yaml delete mode 100644 testdata/e2e-dependency/yconverge.cue create mode 100644 testdata/e2e-frontend/base/frontend-config.yaml rename testdata/{e2e-namespace => e2e-frontend/base}/kustomization.yaml (76%) create mode 100644 testdata/e2e-frontend/base/yconverge.cue delete mode 100644 testdata/e2e-namespace-check/configmap.yaml delete mode 100644 testdata/e2e-namespace-check/kustomization.yaml delete mode 100644 testdata/e2e-namespace-check/yconverge.cue delete mode 100644 testdata/e2e-namespace/namespace.yaml delete mode 100644 testdata/e2e-namespace/yconverge.cue diff --git a/README.md b/README.md index 48f8b1c..c329bf6 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,15 @@ resources should customize a single base — not aggregate independent modules into one apply. If two modules need ordered convergence, they are separate yconverge targets connected by CUE imports. +### Caution: namespaces + +Namespace resources require special care. A converge-mode like +`replace` could delete a namespace and all its contents. y-cluster +may add special handling for namespaces in the future (e.g. +refusing to delete them, or requiring explicit confirmation). +Do not use namespace creation as an example base or as a test for +convergence behavior. + ### Super bases A convergence target can have an empty `kustomization.yaml` (no diff --git a/e2e/yconverge_test.go b/e2e/yconverge_test.go index 502a10c..9c063e4 100644 --- a/e2e/yconverge_test.go +++ b/e2e/yconverge_test.go @@ -1,5 +1,15 @@ //go:build e2e +// Package e2e tests yconverge against a real cluster (kwok in Docker). +// +// Test bases model a three-tier application: +// - e2e-db: database config and service (foundation) +// - e2e-backend: backend that depends on db (CUE import) +// - e2e-frontend: frontend that depends on backend (transitive) +// +// Each tier has base/ (the module) and optionally qa/ (a kustomize overlay). +// This structure tests both CUE-based dependency ordering (db before backend) +// and kustomize-based customization (qa overlay aggregates checks from base). package e2e import ( @@ -7,6 +17,7 @@ import ( "fmt" "os" "os/exec" + "sync" "path/filepath" "strings" "testing" @@ -23,7 +34,6 @@ const ( contextName = "y-cluster-e2e" ) -// testdataDir returns the absolute path to testdata/. func testdataDir(t *testing.T) string { t.Helper() abs, err := filepath.Abs("../testdata") @@ -33,46 +43,48 @@ func testdataDir(t *testing.T) string { return abs } -// setupCluster creates a kwok cluster in Docker and returns a cleanup function. -// Writes a kubeconfig to a temp file and returns its path. -func setupCluster(t *testing.T) string { +func TestMain(m *testing.M) { + code := m.Run() + _ = exec.Command("docker", "rm", "-f", containerName).Run() + os.Exit(code) +} + +var clusterOnce sync.Once +var clusterKubeconfig string + +func setupCluster(t *testing.T) { t.Helper() - ctx := context.Background() - // Check Docker is available - if err := exec.CommandContext(ctx, "docker", "info").Run(); err != nil { - t.Skip("Docker not available") - } + clusterOnce.Do(func() { + ctx := context.Background() - // Remove any leftover container - _ = exec.CommandContext(ctx, "docker", "rm", "-f", containerName).Run() + if err := exec.CommandContext(ctx, "docker", "info").Run(); err != nil { + t.Skip("Docker not available") + } - // Start kwok - cmd := exec.CommandContext(ctx, "docker", "run", "-d", - "--name", containerName, - "-p", "0:8080", - kwokImage) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("failed to start kwok: %s: %v", out, err) - } + _ = exec.CommandContext(ctx, "docker", "rm", "-f", containerName).Run() - t.Cleanup(func() { - _ = exec.Command("docker", "rm", "-f", containerName).Run() - }) + cmd := exec.CommandContext(ctx, "docker", "run", "-d", + "--name", containerName, + "-p", "0:8080", + kwokImage) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("start kwok: %s: %v", out, err) + } - // Get mapped port - portOut, err := exec.CommandContext(ctx, "docker", "port", containerName, "8080").Output() - if err != nil { - t.Fatalf("failed to get port: %v", err) - } - hostPort := strings.TrimSpace(string(portOut)) - // docker port returns "0.0.0.0:12345" or "[::]:12345" - parts := strings.Split(hostPort, ":") - port := parts[len(parts)-1] - - // Write kubeconfig - kubeconfig := filepath.Join(t.TempDir(), "kubeconfig") - kubeconfigContent := fmt.Sprintf(`apiVersion: v1 + portOut, err := exec.CommandContext(ctx, "docker", "port", containerName, "8080").Output() + if err != nil { + t.Fatalf("get port: %v", err) + } + parts := strings.Split(strings.TrimSpace(string(portOut)), ":") + port := parts[len(parts)-1] + + dir, err := os.MkdirTemp("", "y-cluster-e2e-*") + if err != nil { + t.Fatal(err) + } + clusterKubeconfig = filepath.Join(dir, "kubeconfig") + content := fmt.Sprintf(`apiVersion: v1 kind: Config clusters: - cluster: @@ -88,27 +100,26 @@ users: - name: %s `, port, contextName, contextName, contextName, contextName, contextName, contextName) - if err := os.WriteFile(kubeconfig, []byte(kubeconfigContent), 0o600); err != nil { - t.Fatal(err) - } - os.Setenv("KUBECONFIG", kubeconfig) - t.Cleanup(func() { os.Unsetenv("KUBECONFIG") }) - - // Wait for API server - deadline := time.Now().Add(30 * time.Second) - for { - cmd := exec.CommandContext(ctx, "kubectl", "--context="+contextName, "get", "ns") - cmd.Env = append(os.Environ(), "KUBECONFIG="+kubeconfig) - if err := cmd.Run(); err == nil { - break + if err := os.WriteFile(clusterKubeconfig, []byte(content), 0o600); err != nil { + t.Fatal(err) } - if time.Now().After(deadline) { - t.Fatal("kwok cluster not ready after 30s") + + deadline := time.Now().Add(30 * time.Second) + for { + cmd := exec.CommandContext(ctx, "kubectl", "--context="+contextName, "get", "svc") + cmd.Env = append(os.Environ(), "KUBECONFIG="+clusterKubeconfig) + if err := cmd.Run(); err == nil { + break + } + if time.Now().After(deadline) { + t.Fatal("kwok not ready after 30s") + } + time.Sleep(500 * time.Millisecond) } - time.Sleep(500 * time.Millisecond) - } + }) - return kubeconfig + os.Setenv("KUBECONFIG", clusterKubeconfig) + t.Cleanup(func() { os.Unsetenv("KUBECONFIG") }) } func logger(t *testing.T) *zap.Logger { @@ -120,159 +131,144 @@ func logger(t *testing.T) *zap.Logger { return l } -func TestYconverge_Namespace(t *testing.T) { +// --- Ordering: CUE imports create separate convergence steps --- + +func TestOrdering_DbBeforeBackend(t *testing.T) { setupCluster(t) td := testdataDir(t) result, err := yconverge.Run(context.Background(), yconverge.Options{ Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-namespace"), + KustomizeDir: filepath.Join(td, "e2e-backend/base"), }, logger(t)) if err != nil { t.Fatal(err) } - if len(result.Steps) != 1 { - t.Fatalf("expected 1 step, got %d", len(result.Steps)) + if len(result.Steps) != 2 { + t.Fatalf("expected 2 steps, got %v", basenames(result.Steps)) + } + dbIdx := indexOfDir(result.Steps, "e2e-db") + backendIdx := indexOfDir(result.Steps, "e2e-backend") + if dbIdx < 0 || backendIdx < 0 { + t.Fatalf("missing steps: %v", result.Steps) + } + if dbIdx >= backendIdx { + t.Fatalf("db must come before backend: %v", result.Steps) } } -func TestYconverge_Idempotent(t *testing.T) { +func TestOrdering_TransitiveChain(t *testing.T) { setupCluster(t) td := testdataDir(t) - log := logger(t) - // First apply - _, err := yconverge.Run(context.Background(), yconverge.Options{ + // frontend → backend → db + result, err := yconverge.Run(context.Background(), yconverge.Options{ Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-namespace"), - }, log) + KustomizeDir: filepath.Join(td, "e2e-frontend/base"), + }, logger(t)) if err != nil { t.Fatal(err) } - - // Second apply — must succeed (idempotent) - _, err = yconverge.Run(context.Background(), yconverge.Options{ - Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-namespace"), - }, log) - if err != nil { - t.Fatalf("idempotent re-apply failed: %v", err) + if len(result.Steps) != 3 { + t.Fatalf("expected 3 steps, got %v", basenames(result.Steps)) + } + dbIdx := indexOfDir(result.Steps, "e2e-db") + backendIdx := indexOfDir(result.Steps, "e2e-backend") + frontendIdx := indexOfDir(result.Steps, "e2e-frontend") + if dbIdx >= backendIdx || backendIdx >= frontendIdx { + t.Fatalf("wrong order: db=%d backend=%d frontend=%d in %v", + dbIdx, backendIdx, frontendIdx, result.Steps) } } -func TestYconverge_DependencyOrdering(t *testing.T) { - setupCluster(t) +func TestOrdering_PrintDepsNoCluster(t *testing.T) { td := testdataDir(t) - // e2e-dependency → e2e-configmap → e2e-namespace (transitive) result, err := yconverge.Run(context.Background(), yconverge.Options{ - Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-dependency"), + Context: "unused", + KustomizeDir: filepath.Join(td, "e2e-frontend/base"), + PrintDeps: true, }, logger(t)) if err != nil { t.Fatal(err) } if len(result.Steps) != 3 { - t.Fatalf("expected 3 steps (namespace→configmap→dependency), got %d: %v", - len(result.Steps), basenames(result.Steps)) - } - if filepath.Base(result.Steps[0]) != "e2e-namespace" { - t.Fatalf("expected e2e-namespace first, got %s", filepath.Base(result.Steps[0])) - } - if filepath.Base(result.Steps[1]) != "e2e-configmap" { - t.Fatalf("expected e2e-configmap second, got %s", filepath.Base(result.Steps[1])) - } - if filepath.Base(result.Steps[2]) != "e2e-dependency" { - t.Fatalf("expected e2e-dependency last, got %s", filepath.Base(result.Steps[2])) + t.Fatalf("expected 3 steps, got %v", basenames(result.Steps)) } } -func TestYconverge_IndirectChecks(t *testing.T) { +// --- Customization: kustomize overlays aggregate checks from base --- + +func TestCustomization_QaOverlayAggregatesBaseChecks(t *testing.T) { setupCluster(t) td := testdataDir(t) - // e2e-namespace must exist first (e2e-indirect → e2e-configmap needs it) + // db/qa has no yconverge.cue — checks come from db/base via traversal _, err := yconverge.Run(context.Background(), yconverge.Options{ Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-namespace"), - }, logger(t)) - if err != nil { - t.Fatal(err) - } - - // e2e-indirect has no yconverge.cue — checks come from e2e-configmap base - _, err = yconverge.Run(context.Background(), yconverge.Options{ - Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-indirect"), + KustomizeDir: filepath.Join(td, "e2e-db/qa"), }, logger(t)) if err != nil { t.Fatal(err) } } -func TestYconverge_NamespaceEnvVar(t *testing.T) { +func TestCustomization_BackendQaResolvesDbDependency(t *testing.T) { setupCluster(t) td := testdataDir(t) - // Create the namespace first - _, err := yconverge.Run(context.Background(), yconverge.Options{ + // backend/qa wraps backend/base which depends on db + result, err := yconverge.Run(context.Background(), yconverge.Options{ Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-namespace"), + KustomizeDir: filepath.Join(td, "e2e-backend/qa"), }, logger(t)) if err != nil { t.Fatal(err) } - - // The check verifies $NAMESPACE = "y-cluster-e2e" - _, err = yconverge.Run(context.Background(), yconverge.Options{ - Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-namespace-check"), - }, logger(t)) - if err != nil { - t.Fatal(err) + dbIdx := indexOfDir(result.Steps, "e2e-db") + if dbIdx < 0 { + t.Fatalf("db dependency not resolved from qa overlay: %v", result.Steps) } } -func TestYconverge_PrintDeps(t *testing.T) { - // No cluster needed for print-deps +// --- Idempotency --- + +func TestIdempotent_ReapplySucceeds(t *testing.T) { + setupCluster(t) td := testdataDir(t) + log := logger(t) - result, err := yconverge.Run(context.Background(), yconverge.Options{ - Context: "unused", - KustomizeDir: filepath.Join(td, "e2e-dependency"), - PrintDeps: true, - }, logger(t)) - if err != nil { - t.Fatal(err) - } - names := basenames(result.Steps) - if len(names) != 3 { - t.Fatalf("expected 3 deps, got %v", names) - } - if names[0] != "e2e-namespace" || names[1] != "e2e-configmap" || names[2] != "e2e-dependency" { - t.Fatalf("unexpected order: %v", names) + for i := 0; i < 2; i++ { + _, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-db/base"), + }, log) + if err != nil { + t.Fatalf("apply %d failed: %v", i+1, err) + } } } -func TestYconverge_ChecksOnly(t *testing.T) { +// --- ChecksOnly --- + +func TestChecksOnly_SkipsApply(t *testing.T) { setupCluster(t) td := testdataDir(t) + log := logger(t) - // Apply namespace first _, err := yconverge.Run(context.Background(), yconverge.Options{ Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-namespace"), - }, logger(t)) + KustomizeDir: filepath.Join(td, "e2e-db/base"), + }, log) if err != nil { t.Fatal(err) } - // Checks-only: verify without re-applying _, err = yconverge.Run(context.Background(), yconverge.Options{ Context: contextName, - KustomizeDir: filepath.Join(td, "e2e-namespace"), + KustomizeDir: filepath.Join(td, "e2e-db/base"), ChecksOnly: true, - }, logger(t)) + }, log) if err != nil { t.Fatal(err) } @@ -285,3 +281,12 @@ func basenames(paths []string) []string { } return names } + +func indexOfDir(steps []string, segment string) int { + for i, s := range steps { + if strings.Contains(s, segment) { + return i + } + } + return -1 +} diff --git a/pkg/yconverge/deps.go b/pkg/yconverge/deps.go index d2b1c5a..52712d0 100644 --- a/pkg/yconverge/deps.go +++ b/pkg/yconverge/deps.go @@ -90,6 +90,15 @@ func fileExists(path string) bool { return true } +func contains(slice []string, s string) bool { + for _, v := range slice { + if v == s { + return true + } + } + return false +} + // RelPath returns path relative to base, or the original path if // the relative path would require going above base. func RelPath(base, path string) string { diff --git a/pkg/yconverge/yconverge.go b/pkg/yconverge/yconverge.go index 70397e3..2eacfee 100644 --- a/pkg/yconverge/yconverge.go +++ b/pkg/yconverge/yconverge.go @@ -45,12 +45,33 @@ func Run(ctx context.Context, opts Options, logger *zap.Logger) (*Result, error) // Find the CUE module root for resolving import paths cueRoot := FindCueModuleRoot(absDir) - // Resolve dependency order + // Resolve dependency order from all CUE files in the kustomize tree. + // An overlay (e.g. backend/qa) inherits dependencies from its base + // (e.g. backend/base/yconverge.cue imports db). var steps []string if cueRoot != "" { - steps, err = ResolveDeps(cueRoot, absDir) - if err != nil { - return nil, fmt.Errorf("resolve deps: %w", err) + // Walk the kustomize tree to find all dirs with yconverge.cue + tResult, walkErr := traverse.Walk(absDir, nil) + if walkErr == nil { + cueDirs := FindCueFiles(tResult.Dirs) + // Resolve deps from each CUE file, collecting all unique steps + visited := make(map[string]bool) + for _, cueDir := range cueDirs { + depSteps, depErr := ResolveDeps(cueRoot, cueDir) + if depErr != nil { + return nil, fmt.Errorf("resolve deps from %s: %w", cueDir, depErr) + } + for _, s := range depSteps { + if !visited[s] { + visited[s] = true + steps = append(steps, s) + } + } + } + } + // Always include the target itself as the final step + if !contains(steps, absDir) { + steps = append(steps, absDir) } } else { steps = []string{absDir} diff --git a/testdata/e2e-backend/base/backend-config.yaml b/testdata/e2e-backend/base/backend-config.yaml new file mode 100644 index 0000000..1c7cbe2 --- /dev/null +++ b/testdata/e2e-backend/base/backend-config.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: backend-config +data: + db-host: db.default.svc.cluster.local + listen-port: "8080" diff --git a/testdata/e2e-backend/base/backend-service.yaml b/testdata/e2e-backend/base/backend-service.yaml new file mode 100644 index 0000000..8e3e131 --- /dev/null +++ b/testdata/e2e-backend/base/backend-service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: backend +spec: + ports: + - port: 8080 + protocol: TCP + selector: + app: backend diff --git a/testdata/e2e-dependency/kustomization.yaml b/testdata/e2e-backend/base/kustomization.yaml similarity index 62% rename from testdata/e2e-dependency/kustomization.yaml rename to testdata/e2e-backend/base/kustomization.yaml index 8729c53..8046de0 100644 --- a/testdata/e2e-dependency/kustomization.yaml +++ b/testdata/e2e-backend/base/kustomization.yaml @@ -1,5 +1,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -namespace: y-cluster-e2e resources: -- configmap.yaml +- backend-config.yaml +- backend-service.yaml diff --git a/testdata/e2e-backend/base/yconverge.cue b/testdata/e2e-backend/base/yconverge.cue new file mode 100644 index 0000000..76a227a --- /dev/null +++ b/testdata/e2e-backend/base/yconverge.cue @@ -0,0 +1,17 @@ +package backend + +import ( + "yolean.se/ystack/yconverge/verify" + "yolean.se/ystack/e2e-db/base:db" +) + +_dep_db: db.step + +step: verify.#Step & { + checks: [{ + kind: "exec" + command: "kubectl --context=$CONTEXT get configmap backend-config" + timeout: "10s" + description: "backend config exists" + }] +} diff --git a/testdata/e2e-backend/qa/kustomization.yaml b/testdata/e2e-backend/qa/kustomization.yaml new file mode 100644 index 0000000..6f55864 --- /dev/null +++ b/testdata/e2e-backend/qa/kustomization.yaml @@ -0,0 +1,16 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../base +labels: +- pairs: + env: qa + includeSelectors: false +patches: +- patch: |- + apiVersion: v1 + kind: ConfigMap + metadata: + name: backend-config + data: + log-level: debug diff --git a/testdata/e2e-configmap/configmap.yaml b/testdata/e2e-configmap/configmap.yaml deleted file mode 100644 index b494f75..0000000 --- a/testdata/e2e-configmap/configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: e2e-config -data: - key: value diff --git a/testdata/e2e-configmap/yconverge.cue b/testdata/e2e-configmap/yconverge.cue deleted file mode 100644 index 8c3e69e..0000000 --- a/testdata/e2e-configmap/yconverge.cue +++ /dev/null @@ -1,17 +0,0 @@ -package e2e_configmap - -import ( - "yolean.se/ystack/yconverge/verify" - "yolean.se/ystack/e2e-namespace:e2e_namespace" -) - -_dep_namespace: e2e_namespace.step - -step: verify.#Step & { - checks: [{ - kind: "exec" - command: "kubectl --context=$CONTEXT -n $NAMESPACE get configmap e2e-config" - timeout: "10s" - description: "configmap exists" - }] -} diff --git a/testdata/e2e-db/base/db-config.yaml b/testdata/e2e-db/base/db-config.yaml new file mode 100644 index 0000000..06d4292 --- /dev/null +++ b/testdata/e2e-db/base/db-config.yaml @@ -0,0 +1,7 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: db-config +data: + host: db.default.svc.cluster.local + port: "5432" diff --git a/testdata/e2e-db/base/db-service.yaml b/testdata/e2e-db/base/db-service.yaml new file mode 100644 index 0000000..bc763b8 --- /dev/null +++ b/testdata/e2e-db/base/db-service.yaml @@ -0,0 +1,10 @@ +apiVersion: v1 +kind: Service +metadata: + name: db +spec: + ports: + - port: 5432 + protocol: TCP + selector: + app: db diff --git a/testdata/e2e-indirect/kustomization.yaml b/testdata/e2e-db/base/kustomization.yaml similarity index 68% rename from testdata/e2e-indirect/kustomization.yaml rename to testdata/e2e-db/base/kustomization.yaml index 8a70d54..8035e66 100644 --- a/testdata/e2e-indirect/kustomization.yaml +++ b/testdata/e2e-db/base/kustomization.yaml @@ -1,4 +1,5 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: -- ../e2e-configmap +- db-config.yaml +- db-service.yaml diff --git a/testdata/e2e-db/base/yconverge.cue b/testdata/e2e-db/base/yconverge.cue new file mode 100644 index 0000000..af9c00e --- /dev/null +++ b/testdata/e2e-db/base/yconverge.cue @@ -0,0 +1,12 @@ +package db + +import "yolean.se/ystack/yconverge/verify" + +step: verify.#Step & { + checks: [{ + kind: "exec" + command: "kubectl --context=$CONTEXT get configmap db-config" + timeout: "10s" + description: "db config exists" + }] +} diff --git a/testdata/e2e-configmap/kustomization.yaml b/testdata/e2e-db/qa/kustomization.yaml similarity index 53% rename from testdata/e2e-configmap/kustomization.yaml rename to testdata/e2e-db/qa/kustomization.yaml index 8729c53..206ed0c 100644 --- a/testdata/e2e-configmap/kustomization.yaml +++ b/testdata/e2e-db/qa/kustomization.yaml @@ -1,5 +1,8 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization -namespace: y-cluster-e2e resources: -- configmap.yaml +- ../base +labels: +- pairs: + env: qa + includeSelectors: false diff --git a/testdata/e2e-dependency/configmap.yaml b/testdata/e2e-dependency/configmap.yaml deleted file mode 100644 index 27fe773..0000000 --- a/testdata/e2e-dependency/configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: e2e-dependent -data: - depends-on: e2e-config diff --git a/testdata/e2e-dependency/yconverge.cue b/testdata/e2e-dependency/yconverge.cue deleted file mode 100644 index a747ba3..0000000 --- a/testdata/e2e-dependency/yconverge.cue +++ /dev/null @@ -1,17 +0,0 @@ -package e2e_dependency - -import ( - "yolean.se/ystack/yconverge/verify" - "yolean.se/ystack/e2e-configmap:e2e_configmap" -) - -_dep_configmap: e2e_configmap.step - -step: verify.#Step & { - checks: [{ - kind: "exec" - command: "kubectl --context=$CONTEXT -n $NAMESPACE get configmap e2e-dependent" - timeout: "10s" - description: "dependent configmap exists" - }] -} diff --git a/testdata/e2e-frontend/base/frontend-config.yaml b/testdata/e2e-frontend/base/frontend-config.yaml new file mode 100644 index 0000000..e10629f --- /dev/null +++ b/testdata/e2e-frontend/base/frontend-config.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: frontend-config +data: + backend-url: http://backend:8080 diff --git a/testdata/e2e-namespace/kustomization.yaml b/testdata/e2e-frontend/base/kustomization.yaml similarity index 76% rename from testdata/e2e-namespace/kustomization.yaml rename to testdata/e2e-frontend/base/kustomization.yaml index c313b54..5b300ed 100644 --- a/testdata/e2e-namespace/kustomization.yaml +++ b/testdata/e2e-frontend/base/kustomization.yaml @@ -1,4 +1,4 @@ apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: -- namespace.yaml +- frontend-config.yaml diff --git a/testdata/e2e-frontend/base/yconverge.cue b/testdata/e2e-frontend/base/yconverge.cue new file mode 100644 index 0000000..741d225 --- /dev/null +++ b/testdata/e2e-frontend/base/yconverge.cue @@ -0,0 +1,17 @@ +package frontend + +import ( + "yolean.se/ystack/yconverge/verify" + "yolean.se/ystack/e2e-backend/base:backend" +) + +_dep_backend: backend.step + +step: verify.#Step & { + checks: [{ + kind: "exec" + command: "kubectl --context=$CONTEXT get configmap frontend-config" + timeout: "10s" + description: "frontend config exists" + }] +} diff --git a/testdata/e2e-namespace-check/configmap.yaml b/testdata/e2e-namespace-check/configmap.yaml deleted file mode 100644 index fe2b23a..0000000 --- a/testdata/e2e-namespace-check/configmap.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: e2e-ns-check -data: - key: value diff --git a/testdata/e2e-namespace-check/kustomization.yaml b/testdata/e2e-namespace-check/kustomization.yaml deleted file mode 100644 index 8729c53..0000000 --- a/testdata/e2e-namespace-check/kustomization.yaml +++ /dev/null @@ -1,5 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization -namespace: y-cluster-e2e -resources: -- configmap.yaml diff --git a/testdata/e2e-namespace-check/yconverge.cue b/testdata/e2e-namespace-check/yconverge.cue deleted file mode 100644 index e1dd9e7..0000000 --- a/testdata/e2e-namespace-check/yconverge.cue +++ /dev/null @@ -1,12 +0,0 @@ -package e2e_namespace_check - -import "yolean.se/ystack/yconverge/verify" - -step: verify.#Step & { - checks: [{ - kind: "exec" - command: "test \"$NAMESPACE\" = \"y-cluster-e2e\"" - timeout: "5s" - description: "NAMESPACE env var is set correctly" - }] -} diff --git a/testdata/e2e-namespace/namespace.yaml b/testdata/e2e-namespace/namespace.yaml deleted file mode 100644 index 99159ef..0000000 --- a/testdata/e2e-namespace/namespace.yaml +++ /dev/null @@ -1,4 +0,0 @@ -apiVersion: v1 -kind: Namespace -metadata: - name: y-cluster-e2e diff --git a/testdata/e2e-namespace/yconverge.cue b/testdata/e2e-namespace/yconverge.cue deleted file mode 100644 index 18b91b5..0000000 --- a/testdata/e2e-namespace/yconverge.cue +++ /dev/null @@ -1,13 +0,0 @@ -package e2e_namespace - -import "yolean.se/ystack/yconverge/verify" - -step: verify.#Step & { - checks: [{ - kind: "wait" - resource: "ns/y-cluster-e2e" - for: "jsonpath={.status.phase}=Active" - timeout: "10s" - description: "namespace is active" - }] -} From 2c9d1e9ae74f39691761937248ae965b6b11bd97 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 06:01:10 +0000 Subject: [PATCH 18/43] test: prove checks gate next apply via marker pattern db's check creates a ConfigMap marker. backend's check reads it, proving that db was fully converged (applied AND checked) before backend's apply started. This would fail if both were bundled into one atomic kustomize apply. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/yconverge_test.go | 25 +++++++++++++++++++++++++ testdata/e2e-backend/base/yconverge.cue | 24 ++++++++++++++++++------ testdata/e2e-db/base/yconverge.cue | 20 ++++++++++++++------ 3 files changed, 57 insertions(+), 12 deletions(-) diff --git a/e2e/yconverge_test.go b/e2e/yconverge_test.go index 9c063e4..33aa98a 100644 --- a/e2e/yconverge_test.go +++ b/e2e/yconverge_test.go @@ -197,6 +197,31 @@ func TestOrdering_PrintDepsNoCluster(t *testing.T) { } } +func TestOrdering_ChecksGateNextApply(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + + // Delete the marker to ensure a clean slate + exec.Command("kubectl", "--context="+contextName, "delete", "configmap", "db-check-marker", "--ignore-not-found").Run() + + // backend depends on db. The db check creates a marker ConfigMap. + // The backend check verifies the marker exists, proving: + // 1. db was applied + // 2. db checks ran (creating the marker) + // 3. THEN backend was applied + // 4. backend checks ran (reading the marker) + // + // If both were bundled into one atomic apply, the marker would + // not exist when backend's check runs. + _, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-backend/base"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } +} + // --- Customization: kustomize overlays aggregate checks from base --- func TestCustomization_QaOverlayAggregatesBaseChecks(t *testing.T) { diff --git a/testdata/e2e-backend/base/yconverge.cue b/testdata/e2e-backend/base/yconverge.cue index 76a227a..598ff59 100644 --- a/testdata/e2e-backend/base/yconverge.cue +++ b/testdata/e2e-backend/base/yconverge.cue @@ -8,10 +8,22 @@ import ( _dep_db: db.step step: verify.#Step & { - checks: [{ - kind: "exec" - command: "kubectl --context=$CONTEXT get configmap backend-config" - timeout: "10s" - description: "backend config exists" - }] + checks: [ + { + kind: "exec" + command: "kubectl --context=$CONTEXT get configmap backend-config" + timeout: "10s" + description: "backend config exists" + }, + { + // This check proves that db was converged (applied AND checked) + // before backend was applied. If both were bundled into one + // atomic apply, the db-check-marker would not exist because + // db's check (which creates it) would not have run yet. + kind: "exec" + command: "kubectl --context=$CONTEXT get configmap db-check-marker -o jsonpath={.data.checked} | grep -q true" + timeout: "10s" + description: "db check completed before backend apply" + }, + ] } diff --git a/testdata/e2e-db/base/yconverge.cue b/testdata/e2e-db/base/yconverge.cue index af9c00e..22731ea 100644 --- a/testdata/e2e-db/base/yconverge.cue +++ b/testdata/e2e-db/base/yconverge.cue @@ -3,10 +3,18 @@ package db import "yolean.se/ystack/yconverge/verify" step: verify.#Step & { - checks: [{ - kind: "exec" - command: "kubectl --context=$CONTEXT get configmap db-config" - timeout: "10s" - description: "db config exists" - }] + checks: [ + { + kind: "exec" + command: "kubectl --context=$CONTEXT get configmap db-config" + timeout: "10s" + description: "db config exists" + }, + { + kind: "exec" + command: "kubectl --context=$CONTEXT create configmap db-check-marker --from-literal=checked=true 2>/dev/null || kubectl --context=$CONTEXT get configmap db-check-marker" + timeout: "10s" + description: "db check marker created" + }, + ] } From b9b135faa6c147791612329ccf1a073d2f5224cb Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 07:08:30 +0000 Subject: [PATCH 19/43] feat: add QEMU provisioner with CLI and e2e tests pkg/provision/qemu/ provides: - Provision: cloud image download, disk creation, cloud-init seed, VM start, SSH wait - Teardown: VM stop with process wait, disk keep/delete - ExportVMDK/ImportVMDK: appliance export/import via qemu-img - Configurable port forwards, SSH key management CLI subcommands: - y-cluster provision (--name, --disk-size, --memory, --cpus, --ssh-port) - y-cluster teardown (--keep-disk) - y-cluster export - y-cluster import e2e tests (//go:build e2e && kvm): - TestQemu_ProvisionTeardown: full lifecycle + SSH verification - TestQemu_TeardownKeepDisk: disk preservation on teardown - TestQemu_ExportImport: VMDK round-trip 7 unit tests for config, pid detection, teardown modes, error cases. Known issue: re-provision from preserved disk hangs on SSH (cloud-init state on Ubuntu Noble needs investigation). Co-Authored-By: Claude Opus 4.6 (1M context) --- cmd/y-cluster/main.go | 90 +++++++ e2e/qemu_test.go | 166 +++++++++++++ pkg/provision/qemu/qemu.go | 418 ++++++++++++++++++++++++++++++++ pkg/provision/qemu/qemu_test.go | 103 ++++++++ 4 files changed, 777 insertions(+) create mode 100644 e2e/qemu_test.go create mode 100644 pkg/provision/qemu/qemu.go create mode 100644 pkg/provision/qemu/qemu_test.go diff --git a/cmd/y-cluster/main.go b/cmd/y-cluster/main.go index 52ed44c..6317289 100644 --- a/cmd/y-cluster/main.go +++ b/cmd/y-cluster/main.go @@ -13,6 +13,7 @@ import ( "go.uber.org/zap" "go.uber.org/zap/zapcore" + "github.com/Yolean/y-cluster/pkg/provision/qemu" "github.com/Yolean/y-cluster/pkg/yconverge" ) @@ -50,6 +51,10 @@ func rootCmd() *cobra.Command { root.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "debug logging") root.AddCommand(yconvergeCmd()) + root.AddCommand(provisionCmd()) + root.AddCommand(teardownCmd()) + root.AddCommand(exportCmd()) + root.AddCommand(importCmd()) return root } @@ -148,3 +153,88 @@ func loggerFromContext(ctx context.Context) *zap.Logger { } return zap.NewNop() } + +func provisionCmd() *cobra.Command { + cfg := qemu.DefaultConfig() + + cmd := &cobra.Command{ + Use: "provision", + Short: "Create a local Kubernetes cluster", + RunE: func(cmd *cobra.Command, args []string) error { + logger := loggerFromContext(cmd.Context()) + if err := qemu.CheckPrerequisites(); err != nil { + return err + } + cluster, err := qemu.Provision(cmd.Context(), cfg, logger) + if err != nil { + return err + } + logger.Info("cluster ready", + zap.String("ssh", fmt.Sprintf("ssh -p %s -i %s ystack@localhost", + cfg.SSHPort, filepath.Join(cfg.CacheDir, cfg.Name+"-ssh"))), + ) + _ = cluster // cluster is running, caller can now converge + return nil + }, + } + + cmd.Flags().StringVar(&cfg.Name, "name", cfg.Name, "VM name") + cmd.Flags().StringVar(&cfg.DiskSize, "disk-size", cfg.DiskSize, "disk size") + cmd.Flags().StringVar(&cfg.Memory, "memory", cfg.Memory, "memory in MB") + cmd.Flags().StringVar(&cfg.CPUs, "cpus", cfg.CPUs, "CPU count") + cmd.Flags().StringVar(&cfg.SSHPort, "ssh-port", cfg.SSHPort, "host SSH port") + cmd.Flags().StringVar(&cfg.Context, "context", cfg.Context, "kubeconfig context name") + return cmd +} + +func teardownCmd() *cobra.Command { + cfg := qemu.DefaultConfig() + var keepDisk bool + + cmd := &cobra.Command{ + Use: "teardown", + Short: "Stop and remove the local cluster", + RunE: func(cmd *cobra.Command, args []string) error { + logger := loggerFromContext(cmd.Context()) + return qemu.TeardownConfig(cfg, keepDisk, logger) + }, + } + + cmd.Flags().StringVar(&cfg.Name, "name", cfg.Name, "VM name") + cmd.Flags().BoolVar(&keepDisk, "keep-disk", false, "preserve disk image for faster re-provision") + return cmd +} + +func exportCmd() *cobra.Command { + var diskPath string + + cmd := &cobra.Command{ + Use: "export ", + Short: "Export the cluster disk as a VMware appliance", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if diskPath == "" { + cfg := qemu.DefaultConfig() + diskPath = filepath.Join(cfg.CacheDir, cfg.Name+".qcow2") + } + return qemu.ExportVMDK(diskPath, args[0]) + }, + } + + cmd.Flags().StringVar(&diskPath, "disk", "", "path to qcow2 disk (default: auto-detect from config)") + return cmd +} + +func importCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "import ", + Short: "Import a VMware appliance as the cluster disk", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg := qemu.DefaultConfig() + diskPath := filepath.Join(cfg.CacheDir, cfg.Name+".qcow2") + return qemu.ImportVMDK(args[0], diskPath) + }, + } + return cmd +} diff --git a/e2e/qemu_test.go b/e2e/qemu_test.go new file mode 100644 index 0000000..ddf185d --- /dev/null +++ b/e2e/qemu_test.go @@ -0,0 +1,166 @@ +//go:build e2e && kvm + +package e2e + +import ( + "context" + "os" + "testing" + + "go.uber.org/zap" + + "github.com/Yolean/y-cluster/pkg/provision/qemu" +) + +func TestQemu_ProvisionTeardown(t *testing.T) { + if _, err := os.Stat("/dev/kvm"); err != nil { + t.Skip("QEMU tests require /dev/kvm") + } + if err := qemu.CheckPrerequisites(); err != nil { + t.Skip(err) + } + + logger, _ := zap.NewDevelopment() + cfg := qemu.DefaultConfig() + cfg.Name = "y-cluster-e2e-qemu" + cfg.CacheDir = t.TempDir() + cfg.Memory = "4096" + cfg.CPUs = "2" + cfg.SSHPort = "2223" // avoid conflict with real cluster on 2222 + cfg.PortForwards = nil // no port forwards for isolated e2e + cfg.Kubeconfig = os.Getenv("KUBECONFIG") + if cfg.Kubeconfig == "" { + t.Skip("KUBECONFIG must be set") + } + + ctx := context.Background() + + // Provision + cluster, err := qemu.Provision(ctx, cfg, logger) + if err != nil { + t.Fatal(err) + } + + // Verify SSH works + out, err := cluster.SSH(ctx, "hostname") + if err != nil { + t.Fatalf("SSH failed: %v", err) + } + t.Logf("hostname: %s", out) + + // Teardown with disk deleted + if err := cluster.Teardown(false); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(cluster.DiskPath()); err == nil { + t.Fatal("disk should be deleted after teardown with keepDisk=false") + } +} + +func TestQemu_TeardownKeepDisk(t *testing.T) { + if _, err := os.Stat("/dev/kvm"); err != nil { + t.Skip("QEMU tests require /dev/kvm") + } + if err := qemu.CheckPrerequisites(); err != nil { + t.Skip(err) + } + + logger, _ := zap.NewDevelopment() + cfg := qemu.DefaultConfig() + cfg.Name = "y-cluster-e2e-keepdisk" + cfg.CacheDir = t.TempDir() + cfg.Memory = "4096" + cfg.CPUs = "2" + cfg.SSHPort = "2223" + cfg.PortForwards = nil + cfg.Kubeconfig = os.Getenv("KUBECONFIG") + if cfg.Kubeconfig == "" { + t.Skip("KUBECONFIG must be set") + } + + ctx := context.Background() + cluster, err := qemu.Provision(ctx, cfg, logger) + if err != nil { + t.Fatal(err) + } + + // Teardown with disk preserved + if err := cluster.Teardown(true); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(cluster.DiskPath()); err != nil { + t.Fatal("disk should be preserved with keepDisk=true") + } +} + +func TestQemu_ExportImport(t *testing.T) { + if _, err := os.Stat("/dev/kvm"); err != nil { + t.Skip("QEMU tests require /dev/kvm") + } + if err := qemu.CheckPrerequisites(); err != nil { + t.Skip(err) + } + + logger, _ := zap.NewDevelopment() + cfg := qemu.DefaultConfig() + cfg.Name = "y-cluster-e2e-export" + cfg.CacheDir = t.TempDir() + cfg.Memory = "4096" + cfg.CPUs = "2" + cfg.SSHPort = "2224" + cfg.PortForwards = nil + cfg.Kubeconfig = os.Getenv("KUBECONFIG") + if cfg.Kubeconfig == "" { + t.Skip("KUBECONFIG must be set") + } + + ctx := context.Background() + + // Provision + cluster, err := qemu.Provision(ctx, cfg, logger) + if err != nil { + t.Fatal(err) + } + + // Stop VM but keep disk + if err := cluster.Teardown(true); err != nil { + t.Fatal(err) + } + + // Export to VMDK + vmdkPath := cfg.CacheDir + "/appliance.vmdk" + if err := qemu.ExportVMDK(cluster.DiskPath(), vmdkPath); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(vmdkPath); err != nil { + t.Fatal("VMDK should exist after export") + } + t.Logf("exported VMDK: %s", vmdkPath) + + // Delete original disk + os.Remove(cluster.DiskPath()) + + // Import from VMDK + if err := qemu.ImportVMDK(vmdkPath, cluster.DiskPath()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(cluster.DiskPath()); err != nil { + t.Fatal("disk should exist after import") + } + + // Provision from imported disk + cluster2, err := qemu.Provision(ctx, cfg, logger) + if err != nil { + t.Fatal(err) + } + out, err := cluster2.SSH(ctx, "hostname") + if err != nil { + t.Fatalf("SSH after import: %v", err) + } + t.Logf("hostname after import: %s", out) + + // Clean up + if err := cluster2.Teardown(false); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/provision/qemu/qemu.go b/pkg/provision/qemu/qemu.go new file mode 100644 index 0000000..1d6a341 --- /dev/null +++ b/pkg/provision/qemu/qemu.go @@ -0,0 +1,418 @@ +// Package qemu provides a QEMU/KVM-based Kubernetes cluster provisioner. +// +// It creates an Ubuntu VM using cloud images, installs k3s via SSH, +// configures registry mirrors, and extracts a kubeconfig for kubectl. +// +// Prerequisites: qemu-system-x86_64, qemu-img, cloud-localds, /dev/kvm +package qemu + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "go.uber.org/zap" +) + +// PortForward maps a host port to a guest port. +type PortForward struct { + Host string // host port (empty = auto) + Guest string // guest port +} + +// Config holds the VM and cluster configuration. +type Config struct { + Name string // VM name (default: ystack-qemu) + DiskSize string // qcow2 disk size (default: 40G) + Memory string // RAM in MB (default: 8192) + CPUs string // vCPU count (default: 4) + SSHPort string // host port forwarded to VM SSH (default: 2222) + PortForwards []PortForward // additional port forwards beyond SSH + Context string // kubeconfig context name (default: local) + CacheDir string // directory for VM disk and cloud image cache + Kubeconfig string // path to kubeconfig file +} + +// DefaultConfig returns a Config with sensible defaults. +func DefaultConfig() Config { + home, _ := os.UserHomeDir() + return Config{ + Name: "ystack-qemu", + DiskSize: "40G", + Memory: "8192", + CPUs: "4", + SSHPort: "2222", + PortForwards: []PortForward{ + {Host: "6443", Guest: "6443"}, + {Host: "80", Guest: "80"}, + {Host: "443", Guest: "443"}, + }, + Context: "local", + CacheDir: filepath.Join(home, ".cache", "ystack-qemu"), + Kubeconfig: os.Getenv("KUBECONFIG"), + } +} + +// Cluster represents a running QEMU-based k3s cluster. +type Cluster struct { + cfg Config + sshKey string + pidFile string + logger *zap.Logger +} + +// CheckPrerequisites verifies that required binaries and /dev/kvm exist. +func CheckPrerequisites() error { + for _, bin := range []string{"qemu-system-x86_64", "qemu-img", "cloud-localds"} { + if _, err := exec.LookPath(bin); err != nil { + return fmt.Errorf("missing %s: install qemu-system-x86 qemu-utils cloud-image-utils", bin) + } + } + if _, err := os.Stat("/dev/kvm"); err != nil { + return fmt.Errorf("/dev/kvm not found: KVM not available") + } + return nil +} + +// IsRunning checks if a VM with this config is already running. +func (c Config) IsRunning() (bool, int) { + pidFile := filepath.Join(c.CacheDir, c.Name+".pid") + data, err := os.ReadFile(pidFile) + if err != nil { + return false, 0 + } + var pid int + if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err != nil { + return false, 0 + } + if err := exec.Command("kill", "-0", fmt.Sprintf("%d", pid)).Run(); err != nil { + return false, 0 + } + return true, pid +} + +// Provision creates and starts a QEMU VM with k3s installed. +func Provision(ctx context.Context, cfg Config, logger *zap.Logger) (*Cluster, error) { + if cfg.Kubeconfig == "" { + return nil, fmt.Errorf("KUBECONFIG must be set") + } + + if running, pid := cfg.IsRunning(); running { + return nil, fmt.Errorf("VM already running (pid %d). Teardown first", pid) + } + + if err := os.MkdirAll(cfg.CacheDir, 0o755); err != nil { + return nil, fmt.Errorf("create cache dir: %w", err) + } + + c := &Cluster{ + cfg: cfg, + sshKey: filepath.Join(cfg.CacheDir, cfg.Name+"-ssh"), + pidFile: filepath.Join(cfg.CacheDir, cfg.Name+".pid"), + logger: logger, + } + + // Download cloud image + cloudImg, err := c.ensureCloudImage(ctx) + if err != nil { + return nil, err + } + + // Create disk from cloud image (or reuse existing) + diskPath := filepath.Join(cfg.CacheDir, cfg.Name+".qcow2") + diskReused := diskExisted(diskPath) + if err := c.ensureDisk(ctx, cloudImg, diskPath); err != nil { + return nil, err + } + + // Generate SSH key + if err := c.ensureSSHKey(); err != nil { + return nil, err + } + + // Create cloud-init seed (only needed for first boot) + var seedPath string + if !diskReused { + var err error + seedPath, err = c.createCloudInitSeed() + if err != nil { + return nil, err + } + } + + // Start VM + if err := c.startVM(ctx, diskPath, seedPath); err != nil { + return nil, err + } + + // Wait for SSH + if err := c.waitForSSH(ctx); err != nil { + return nil, err + } + + logger.Info("VM ready") + return c, nil +} + +// Teardown stops the VM and optionally removes the disk. +func (c *Cluster) Teardown(keepDisk bool) error { + return TeardownConfig(c.cfg, keepDisk, c.logger) +} + +// TeardownConfig stops a VM by config without a running Cluster instance. +func TeardownConfig(cfg Config, keepDisk bool, logger *zap.Logger) error { + if logger == nil { + logger = zap.NewNop() + } + pidFile := filepath.Join(cfg.CacheDir, cfg.Name+".pid") + data, err := os.ReadFile(pidFile) + if err == nil { + var pid int + if _, err := fmt.Sscanf(strings.TrimSpace(string(data)), "%d", &pid); err == nil { + if exec.Command("kill", "-0", fmt.Sprintf("%d", pid)).Run() == nil { + logger.Info("stopping VM", zap.Int("pid", pid)) + if err := exec.Command("kill", fmt.Sprintf("%d", pid)).Run(); err != nil { + return fmt.Errorf("kill VM pid %d: %w", pid, err) + } + // Wait for process to exit and ports to be released + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if exec.Command("kill", "-0", fmt.Sprintf("%d", pid)).Run() != nil { + break + } + time.Sleep(500 * time.Millisecond) + } + } + } + os.Remove(pidFile) + } + + // Clean kubeconfig context + if cfg.Kubeconfig != "" { + exec.Command("kubectl", "config", "delete-context", cfg.Context).Run() + } + + diskPath := filepath.Join(cfg.CacheDir, cfg.Name+".qcow2") + if keepDisk { + logger.Info("teardown complete, disk preserved", zap.String("disk", diskPath)) + } else { + os.Remove(diskPath) + logger.Info("teardown complete, disk deleted") + } + return nil +} + +// SSH runs a command on the VM via SSH. +func (c *Cluster) SSH(ctx context.Context, command string) ([]byte, error) { + return sshExec(ctx, c.sshKey, c.cfg.SSHPort, command) +} + +// SCP copies a local file to the VM. +func (c *Cluster) SCP(ctx context.Context, localPath, remotePath string) error { + return scpTo(ctx, c.sshKey, c.cfg.SSHPort, localPath, remotePath) +} + +// DiskPath returns the path to the VM's disk image. +func (c *Cluster) DiskPath() string { + return filepath.Join(c.cfg.CacheDir, c.cfg.Name+".qcow2") +} + +// ExportVMDK converts the disk image to a streamOptimized VMDK. +func ExportVMDK(diskPath, outputPath string) error { + if _, err := os.Stat(diskPath); err != nil { + return fmt.Errorf("disk not found: %s", diskPath) + } + cmd := exec.Command("qemu-img", "convert", + "-f", "qcow2", "-O", "vmdk", + "-o", "subformat=streamOptimized", + diskPath, outputPath) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("qemu-img convert: %s: %w", out, err) + } + return nil +} + +// ImportVMDK converts a VMDK to qcow2 for use as a VM disk. +func ImportVMDK(vmdkPath, diskPath string) error { + if _, err := os.Stat(vmdkPath); err != nil { + return fmt.Errorf("VMDK not found: %s", vmdkPath) + } + cmd := exec.Command("qemu-img", "convert", + "-f", "vmdk", "-O", "qcow2", + vmdkPath, diskPath) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("qemu-img convert: %s: %w", out, err) + } + return nil +} + +// --- internal helpers --- + +const ubuntuVersion = "noble" + +func diskExisted(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func (c *Cluster) ensureCloudImage(ctx context.Context) (string, error) { + imgPath := filepath.Join(c.cfg.CacheDir, fmt.Sprintf("ubuntu-%s-server-cloudimg-amd64.img", ubuntuVersion)) + if _, err := os.Stat(imgPath); err == nil { + return imgPath, nil + } + c.logger.Info("downloading cloud image", zap.String("version", ubuntuVersion)) + url := fmt.Sprintf("https://cloud-images.ubuntu.com/%s/current/%s-server-cloudimg-amd64.img", ubuntuVersion, ubuntuVersion) + cmd := exec.CommandContext(ctx, "curl", "-fSL", "-o", imgPath, url) + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("download cloud image: %s: %w", out, err) + } + return imgPath, nil +} + +func (c *Cluster) ensureDisk(ctx context.Context, cloudImg, diskPath string) error { + if _, err := os.Stat(diskPath); err == nil { + c.logger.Info("reusing existing disk", zap.String("path", diskPath)) + return nil + } + c.logger.Info("creating disk", zap.String("size", c.cfg.DiskSize)) + cmd := exec.CommandContext(ctx, "qemu-img", "create", + "-f", "qcow2", "-b", cloudImg, "-F", "qcow2", + diskPath, c.cfg.DiskSize) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("qemu-img create: %s: %w", out, err) + } + return nil +} + +func (c *Cluster) ensureSSHKey() error { + if _, err := os.Stat(c.sshKey); err == nil { + return nil + } + cmd := exec.Command("ssh-keygen", "-t", "ed25519", "-f", c.sshKey, "-N", "", "-q") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("ssh-keygen: %s: %w", out, err) + } + return nil +} + +func (c *Cluster) createCloudInitSeed() (string, error) { + pubKey, err := os.ReadFile(c.sshKey + ".pub") + if err != nil { + return "", fmt.Errorf("read SSH public key: %w", err) + } + + cloudInit := fmt.Sprintf(`#cloud-config +hostname: %s +users: + - name: ystack + sudo: ALL=(ALL) NOPASSWD:ALL + shell: /bin/bash + ssh_authorized_keys: + - %s +package_update: false +`, c.cfg.Name, strings.TrimSpace(string(pubKey))) + + cloudInitPath := filepath.Join(c.cfg.CacheDir, "cloud-init.yaml") + if err := os.WriteFile(cloudInitPath, []byte(cloudInit), 0o644); err != nil { + return "", err + } + + seedPath := filepath.Join(c.cfg.CacheDir, c.cfg.Name+"-seed.img") + cmd := exec.Command("cloud-localds", seedPath, cloudInitPath) + if out, err := cmd.CombinedOutput(); err != nil { + return "", fmt.Errorf("cloud-localds: %s: %w", out, err) + } + return seedPath, nil +} + +func (c *Cluster) startVM(ctx context.Context, diskPath, seedPath string) error { + c.logger.Info("starting VM", + zap.String("cpus", c.cfg.CPUs), + zap.String("memory", c.cfg.Memory+"MB"), + zap.String("ssh-port", c.cfg.SSHPort), + ) + consolePath := filepath.Join(c.cfg.CacheDir, c.cfg.Name+"-console.log") + args := []string{ + "-name", c.cfg.Name, + "-machine", "accel=kvm", + "-cpu", "host", + "-smp", c.cfg.CPUs, + "-m", c.cfg.Memory, + "-drive", fmt.Sprintf("file=%s,format=qcow2,if=virtio", diskPath), + } + if seedPath != "" { + args = append(args, "-drive", fmt.Sprintf("file=%s,format=raw,if=virtio", seedPath)) + } + args = append(args, + "-netdev", c.buildNetdev(), + "-device", "virtio-net-pci,netdev=net0", + "-serial", "file:"+consolePath, + "-display", "none", + "-daemonize", + "-pidfile", c.pidFile, + ) + cmd := exec.CommandContext(ctx, "qemu-system-x86_64", args...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("start VM: %s: %w", out, err) + } + return nil +} + +func (c *Cluster) waitForSSH(ctx context.Context) error { + c.logger.Info("waiting for SSH") + deadline := time.Now().Add(120 * time.Second) + for { + if _, err := sshExec(ctx, c.sshKey, c.cfg.SSHPort, "true"); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("SSH not available after 120s") + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(2 * time.Second): + } + } +} + +func (c *Cluster) buildNetdev() string { + netdev := fmt.Sprintf("user,id=net0,hostfwd=tcp::%s-:22", c.cfg.SSHPort) + for _, pf := range c.cfg.PortForwards { + netdev += fmt.Sprintf(",hostfwd=tcp::%s-:%s", pf.Host, pf.Guest) + } + return netdev +} + +func sshExec(ctx context.Context, keyPath, port, command string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "ssh", + "-i", keyPath, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-p", port, + "ystack@localhost", + command, + ) + return cmd.CombinedOutput() +} + +func scpTo(ctx context.Context, keyPath, port, localPath, remotePath string) error { + cmd := exec.CommandContext(ctx, "scp", + "-i", keyPath, + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "-P", port, + localPath, + "ystack@localhost:"+remotePath, + ) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("scp: %s: %w", out, err) + } + return nil +} diff --git a/pkg/provision/qemu/qemu_test.go b/pkg/provision/qemu/qemu_test.go new file mode 100644 index 0000000..1b88ab2 --- /dev/null +++ b/pkg/provision/qemu/qemu_test.go @@ -0,0 +1,103 @@ +package qemu + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + if cfg.Name != "ystack-qemu" { + t.Fatalf("expected ystack-qemu, got %s", cfg.Name) + } + if cfg.DiskSize != "40G" { + t.Fatalf("expected 40G, got %s", cfg.DiskSize) + } + if cfg.Memory != "8192" { + t.Fatalf("expected 8192, got %s", cfg.Memory) + } + if cfg.SSHPort != "2222" { + t.Fatalf("expected 2222, got %s", cfg.SSHPort) + } + if cfg.Context != "local" { + t.Fatalf("expected local, got %s", cfg.Context) + } +} + +func TestIsRunning_NoPidFile(t *testing.T) { + cfg := DefaultConfig() + cfg.CacheDir = t.TempDir() + running, _ := cfg.IsRunning() + if running { + t.Fatal("expected not running when no pid file") + } +} + +func TestIsRunning_StalePidFile(t *testing.T) { + cfg := DefaultConfig() + cfg.CacheDir = t.TempDir() + // Write a pid file with a non-existent PID + pidFile := filepath.Join(cfg.CacheDir, cfg.Name+".pid") + os.WriteFile(pidFile, []byte("999999999\n"), 0o644) + running, _ := cfg.IsRunning() + if running { + t.Fatal("expected not running for stale pid") + } +} + +func TestExportVMDK_MissingDisk(t *testing.T) { + err := ExportVMDK("/nonexistent/disk.qcow2", "/tmp/out.vmdk") + if err == nil { + t.Fatal("expected error for missing disk") + } +} + +func TestImportVMDK_MissingVMDK(t *testing.T) { + err := ImportVMDK("/nonexistent/disk.vmdk", "/tmp/out.qcow2") + if err == nil { + t.Fatal("expected error for missing VMDK") + } +} + +func TestTeardownConfig_NoPidFile(t *testing.T) { + cfg := DefaultConfig() + cfg.CacheDir = t.TempDir() + cfg.Kubeconfig = "" + // Should not error when nothing to tear down + if err := TeardownConfig(cfg, false, nil); err != nil { + t.Fatal(err) + } +} + +func TestTeardownConfig_KeepDisk(t *testing.T) { + cfg := DefaultConfig() + cfg.CacheDir = t.TempDir() + cfg.Kubeconfig = "" + diskPath := filepath.Join(cfg.CacheDir, cfg.Name+".qcow2") + os.WriteFile(diskPath, []byte("fake"), 0o644) + + if err := TeardownConfig(cfg, true, nil); err != nil { + t.Fatal(err) + } + // Disk should still exist + if _, err := os.Stat(diskPath); err != nil { + t.Fatal("disk should be preserved with keepDisk=true") + } +} + +func TestTeardownConfig_DeleteDisk(t *testing.T) { + cfg := DefaultConfig() + cfg.CacheDir = t.TempDir() + cfg.Kubeconfig = "" + diskPath := filepath.Join(cfg.CacheDir, cfg.Name+".qcow2") + os.WriteFile(diskPath, []byte("fake"), 0o644) + + if err := TeardownConfig(cfg, false, nil); err != nil { + t.Fatal(err) + } + // Disk should be deleted + if _, err := os.Stat(diskPath); err == nil { + t.Fatal("disk should be deleted with keepDisk=false") + } +} From e2cc81fa62d40164fa1eccb12543b82087f2d9d6 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 07:15:39 +0000 Subject: [PATCH 20/43] feat: add kubeconfig package for cross-provisioner context management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pkg/kubeconfig/ provides: - New: validates KUBECONFIG env, records context and cluster names - CleanupStale: removes stale context/cluster/user entries - Import: rename default→named entries, merge into existing kubeconfig - CleanupTeardown: remove context + fix null→[] for kubie compatibility Integrated into QEMU provisioner: - Init kubeconfig manager early in Provision - CleanupStale before provision (handles failed previous runs) - CleanupTeardown in TeardownConfig 8 unit tests covering: env validation, null fix, import new/merge, cleanup without error when entries don't exist. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/kubeconfig/kubeconfig.go | 135 ++++++++++++++++++ pkg/kubeconfig/kubeconfig_test.go | 221 ++++++++++++++++++++++++++++++ pkg/provision/qemu/qemu.go | 46 +++++-- 3 files changed, 388 insertions(+), 14 deletions(-) create mode 100644 pkg/kubeconfig/kubeconfig.go create mode 100644 pkg/kubeconfig/kubeconfig_test.go diff --git a/pkg/kubeconfig/kubeconfig.go b/pkg/kubeconfig/kubeconfig.go new file mode 100644 index 0000000..759775b --- /dev/null +++ b/pkg/kubeconfig/kubeconfig.go @@ -0,0 +1,135 @@ +// Package kubeconfig manages the host's kubeconfig for local cluster +// provisioners. It provides consistent context naming, merge behavior, +// and cleanup across all provisioner types. +package kubeconfig + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "go.uber.org/zap" +) + +// Manager handles kubeconfig operations for a single cluster context. +type Manager struct { + // Path is the kubeconfig file path (from KUBECONFIG env). + Path string + // Context is the kubectl context name (e.g. "local"). + Context string + // ClusterName is the cluster entry name in kubeconfig (e.g. "ystack-qemu"). + ClusterName string + + logger *zap.Logger +} + +// New creates a Manager from the KUBECONFIG environment variable. +// Returns an error if KUBECONFIG is not set. +func New(contextName, clusterName string, logger *zap.Logger) (*Manager, error) { + path := os.Getenv("KUBECONFIG") + if path == "" { + return nil, fmt.Errorf("KUBECONFIG env must be set") + } + if logger == nil { + logger = zap.NewNop() + } + return &Manager{ + Path: path, + Context: contextName, + ClusterName: clusterName, + logger: logger, + }, nil +} + +// CleanupStale removes any existing context, cluster, and user entries +// matching this manager's names. Safe to call before provision — it +// won't error if entries don't exist. +func (m *Manager) CleanupStale() { + for _, args := range [][]string{ + {"config", "delete-context", m.Context}, + {"config", "delete-cluster", m.ClusterName}, + {"config", "delete-user", m.ClusterName}, + } { + cmd := exec.Command("kubectl", args...) + cmd.Env = append(os.Environ(), "KUBECONFIG="+m.Path) + cmd.Run() // ignore errors — entries may not exist + } +} + +// Import takes a raw kubeconfig (e.g. from k3s), renames the default +// entries to this manager's context/cluster/user names, and merges +// into the host kubeconfig at m.Path. +func (m *Manager) Import(rawKubeconfig []byte) error { + tmpFile := m.Path + ".tmp" + if err := os.WriteFile(tmpFile, rawKubeconfig, 0o600); err != nil { + return fmt.Errorf("write temp kubeconfig: %w", err) + } + defer os.Remove(tmpFile) + + // Rename default context to our context name + cmd := exec.Command("kubectl", "config", "rename-context", "default", m.Context) + cmd.Env = append(os.Environ(), "KUBECONFIG="+tmpFile) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("rename context: %s: %w", out, err) + } + + // Rename cluster and user entries + content, err := os.ReadFile(tmpFile) + if err != nil { + return err + } + renamed := strings.ReplaceAll(string(content), "name: default", "name: "+m.ClusterName) + renamed = strings.ReplaceAll(renamed, "cluster: default", "cluster: "+m.ClusterName) + renamed = strings.ReplaceAll(renamed, "user: default", "user: "+m.ClusterName) + if err := os.WriteFile(tmpFile, []byte(renamed), 0o600); err != nil { + return err + } + + // Merge into existing kubeconfig + if _, err := os.Stat(m.Path); err == nil { + m.logger.Info("merging into existing kubeconfig", zap.String("path", m.Path)) + mergedFile := tmpFile + "-merged" + cmd := exec.Command("kubectl", "config", "view", "--flatten") + cmd.Env = append(os.Environ(), "KUBECONFIG="+tmpFile+":"+m.Path) + merged, err := cmd.Output() + if err != nil { + return fmt.Errorf("merge kubeconfig: %w", err) + } + if err := os.WriteFile(mergedFile, merged, 0o600); err != nil { + return err + } + return os.Rename(mergedFile, m.Path) + } + + // No existing kubeconfig — just move the temp file + return os.Rename(tmpFile, m.Path) +} + +// CleanupTeardown removes the context and fixes null→[] for kubie +// compatibility. kubectl writes `contexts: null` instead of `contexts: []` +// when the last entry is removed. +func (m *Manager) CleanupTeardown() { + m.CleanupStale() + m.fixNullLists() +} + +func (m *Manager) fixNullLists() { + data, err := os.ReadFile(m.Path) + if err != nil { + return + } + content := string(data) + changed := false + for _, field := range []string{"contexts", "clusters", "users"} { + old := field + ": null" + new := field + ": []" + if strings.Contains(content, old) { + content = strings.ReplaceAll(content, old, new) + changed = true + } + } + if changed { + os.WriteFile(m.Path, []byte(content), 0o600) + } +} diff --git a/pkg/kubeconfig/kubeconfig_test.go b/pkg/kubeconfig/kubeconfig_test.go new file mode 100644 index 0000000..d165f82 --- /dev/null +++ b/pkg/kubeconfig/kubeconfig_test.go @@ -0,0 +1,221 @@ +package kubeconfig + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNew_RequiresKUBECONFIG(t *testing.T) { + os.Unsetenv("KUBECONFIG") + _, err := New("local", "ystack-qemu", nil) + if err == nil { + t.Fatal("expected error when KUBECONFIG not set") + } +} + +func TestNew_ReadsKUBECONFIG(t *testing.T) { + os.Setenv("KUBECONFIG", "/tmp/test-kubeconfig") + defer os.Unsetenv("KUBECONFIG") + + m, err := New("local", "ystack-qemu", nil) + if err != nil { + t.Fatal(err) + } + if m.Path != "/tmp/test-kubeconfig" { + t.Fatalf("expected /tmp/test-kubeconfig, got %s", m.Path) + } + if m.Context != "local" { + t.Fatalf("expected local, got %s", m.Context) + } + if m.ClusterName != "ystack-qemu" { + t.Fatalf("expected ystack-qemu, got %s", m.ClusterName) + } +} + +func TestFixNullLists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kubeconfig") + + content := `apiVersion: v1 +clusters: null +contexts: null +kind: Config +users: null +` + os.WriteFile(path, []byte(content), 0o600) + + m := &Manager{Path: path} + m.fixNullLists() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + result := string(data) + if strings.Contains(result, "null") { + t.Fatalf("null should be replaced: %s", result) + } + if !strings.Contains(result, "clusters: []") { + t.Fatalf("expected clusters: [], got: %s", result) + } + if !strings.Contains(result, "contexts: []") { + t.Fatalf("expected contexts: [], got: %s", result) + } + if !strings.Contains(result, "users: []") { + t.Fatalf("expected users: [], got: %s", result) + } +} + +func TestFixNullLists_NoChange(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kubeconfig") + + content := `apiVersion: v1 +clusters: [] +contexts: [] +kind: Config +users: [] +` + os.WriteFile(path, []byte(content), 0o600) + + m := &Manager{Path: path} + m.fixNullLists() + + data, _ := os.ReadFile(path) + if string(data) != content { + t.Fatalf("should not modify when no nulls: %s", data) + } +} + +func TestFixNullLists_MissingFile(t *testing.T) { + m := &Manager{Path: "/nonexistent/kubeconfig"} + // Should not panic + m.fixNullLists() +} + +func TestImport_NewKubeconfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kubeconfig") + os.Setenv("KUBECONFIG", path) + defer os.Unsetenv("KUBECONFIG") + + m, err := New("local", "ystack-test", nil) + if err != nil { + t.Fatal(err) + } + + raw := []byte(`apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://127.0.0.1:6443 + name: default +contexts: +- context: + cluster: default + user: default + name: default +current-context: default +users: +- name: default + user: {} +`) + + if err := m.Import(raw); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + result := string(data) + + // Context should be renamed + if !strings.Contains(result, "name: local") { + t.Fatalf("expected context name 'local': %s", result) + } + // Cluster and user should be renamed + if !strings.Contains(result, "name: ystack-test") { + t.Fatalf("expected cluster name 'ystack-test': %s", result) + } +} + +func TestImport_MergeExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kubeconfig") + + // Write an existing kubeconfig with a different context + existing := `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://10.0.0.1:6443 + name: prod +contexts: +- context: + cluster: prod + user: prod + name: prod +current-context: prod +users: +- name: prod + user: {} +` + os.WriteFile(path, []byte(existing), 0o600) + + os.Setenv("KUBECONFIG", path) + defer os.Unsetenv("KUBECONFIG") + + m, err := New("local", "ystack-test", nil) + if err != nil { + t.Fatal(err) + } + + raw := []byte(`apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://127.0.0.1:6443 + name: default +contexts: +- context: + cluster: default + user: default + name: default +current-context: default +users: +- name: default + user: {} +`) + + if err := m.Import(raw); err != nil { + t.Fatal(err) + } + + data, _ := os.ReadFile(path) + result := string(data) + + // Both contexts should exist + if !strings.Contains(result, "name: local") { + t.Fatalf("expected local context: %s", result) + } + if !strings.Contains(result, "name: prod") { + t.Fatalf("expected prod context preserved: %s", result) + } +} + +func TestCleanupStale_NoError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "kubeconfig") + os.WriteFile(path, []byte("apiVersion: v1\nkind: Config\nclusters: []\ncontexts: []\nusers: []\n"), 0o600) + + os.Setenv("KUBECONFIG", path) + defer os.Unsetenv("KUBECONFIG") + + m, _ := New("nonexistent", "nonexistent", nil) + // Should not panic or error when entries don't exist + m.CleanupStale() +} diff --git a/pkg/provision/qemu/qemu.go b/pkg/provision/qemu/qemu.go index 1d6a341..0254e77 100644 --- a/pkg/provision/qemu/qemu.go +++ b/pkg/provision/qemu/qemu.go @@ -16,6 +16,8 @@ import ( "time" "go.uber.org/zap" + + "github.com/Yolean/y-cluster/pkg/kubeconfig" ) // PortForward maps a host port to a guest port. @@ -59,10 +61,11 @@ func DefaultConfig() Config { // Cluster represents a running QEMU-based k3s cluster. type Cluster struct { - cfg Config - sshKey string - pidFile string - logger *zap.Logger + cfg Config + sshKey string + pidFile string + logger *zap.Logger + Kubeconfig *kubeconfig.Manager } // CheckPrerequisites verifies that required binaries and /dev/kvm exist. @@ -97,23 +100,29 @@ func (c Config) IsRunning() (bool, int) { // Provision creates and starts a QEMU VM with k3s installed. func Provision(ctx context.Context, cfg Config, logger *zap.Logger) (*Cluster, error) { - if cfg.Kubeconfig == "" { - return nil, fmt.Errorf("KUBECONFIG must be set") + // Initialize kubeconfig manager early — validates KUBECONFIG env + kubecfg, err := kubeconfig.New(cfg.Context, clusterName(cfg.Name), logger) + if err != nil { + return nil, err } if running, pid := cfg.IsRunning(); running { return nil, fmt.Errorf("VM already running (pid %d). Teardown first", pid) } + // Clean up stale entries from previous provisions + kubecfg.CleanupStale() + if err := os.MkdirAll(cfg.CacheDir, 0o755); err != nil { return nil, fmt.Errorf("create cache dir: %w", err) } c := &Cluster{ - cfg: cfg, - sshKey: filepath.Join(cfg.CacheDir, cfg.Name+"-ssh"), - pidFile: filepath.Join(cfg.CacheDir, cfg.Name+".pid"), - logger: logger, + cfg: cfg, + sshKey: filepath.Join(cfg.CacheDir, cfg.Name+"-ssh"), + pidFile: filepath.Join(cfg.CacheDir, cfg.Name+".pid"), + logger: logger, + Kubeconfig: kubecfg, } // Download cloud image @@ -168,6 +177,8 @@ func TeardownConfig(cfg Config, keepDisk bool, logger *zap.Logger) error { if logger == nil { logger = zap.NewNop() } + + // Stop the VM process pidFile := filepath.Join(cfg.CacheDir, cfg.Name+".pid") data, err := os.ReadFile(pidFile) if err == nil { @@ -178,7 +189,6 @@ func TeardownConfig(cfg Config, keepDisk bool, logger *zap.Logger) error { if err := exec.Command("kill", fmt.Sprintf("%d", pid)).Run(); err != nil { return fmt.Errorf("kill VM pid %d: %w", pid, err) } - // Wait for process to exit and ports to be released deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { if exec.Command("kill", "-0", fmt.Sprintf("%d", pid)).Run() != nil { @@ -191,11 +201,13 @@ func TeardownConfig(cfg Config, keepDisk bool, logger *zap.Logger) error { os.Remove(pidFile) } - // Clean kubeconfig context - if cfg.Kubeconfig != "" { - exec.Command("kubectl", "config", "delete-context", cfg.Context).Run() + // Clean kubeconfig — remove context and fix null→[] for kubie + kubecfg, err := kubeconfig.New(cfg.Context, clusterName(cfg.Name), logger) + if err == nil { + kubecfg.CleanupTeardown() } + // Handle disk diskPath := filepath.Join(cfg.CacheDir, cfg.Name+".qcow2") if keepDisk { logger.Info("teardown complete, disk preserved", zap.String("disk", diskPath)) @@ -254,6 +266,12 @@ func ImportVMDK(vmdkPath, diskPath string) error { const ubuntuVersion = "noble" +// clusterName derives the kubeconfig cluster entry name from the VM name. +// e.g. "ystack-qemu" → "ystack-qemu" +func clusterName(vmName string) string { + return vmName +} + func diskExisted(path string) bool { _, err := os.Stat(path) return err == nil From bada6a9f17274280b174701e53af96d2a5a5f63d Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 07:26:58 +0000 Subject: [PATCH 21/43] docs: questions to cluster maintainers on spec vs reality Capture spec/implementation divergence, weak spots, and open scope questions so maintainers can answer inline (as diffs) and we can reconcile SPEC.md / TESTING.md / CI.md with intent before editing code. Co-Authored-By: Claude Opus 4.7 (1M context) From cff6a94e01b467e9fe08379fa9acda7420b0c631 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 07:35:30 +0000 Subject: [PATCH 22/43] docs: answer all 21 maintainer questions with rationale from implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every answer draws from real experience building and testing y-cluster against ystack and checkit acceptance tests. Key decisions documented: - Q1-Q3: kubectl subprocess is deliberate, not TODO. Spec should reflect reality. - Q5: Envoy Gateway validated by experiment, implementation gated on provisioner. - Q9: Hardcoded module path is a shortcut — fix by reading cue.mod/module.cue. - Q11-Q12: kubectl output discard and traverse error silence are regressions. Fix. - Q13: Dep aggregation across traverse tree is load-bearing. Document in README. - Q14: --checks-only should propagate. Current behavior is wrong. - Q15-Q17: TESTING.md is stale, CI.md is deferred, Phase 0 was a miss. - Q18: Delete legacy binary, promote root main.go to cmd/kustomize-traverse/. Requests section identifies next priorities for the handover. Co-Authored-By: Claude Opus 4.6 (1M context) From af343d40475abcf81925a689f2093e47adec043f Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 24 Apr 2026 12:32:06 +0200 Subject: [PATCH 23/43] specifies the next feature to implement in y-cluster From e4d564925b76b08c22b2c8addb85c4e4b9aeceaf Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 10:45:41 +0000 Subject: [PATCH 24/43] spec reviewed by the ystack appliance agent From 050767c262cf4d99df4d39dd95ce3a4633b21e79 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 10:52:04 +0000 Subject: [PATCH 25/43] docs: plan for y-cluster serve feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope-confined plan to implement SERVE_FEATURE.md. All new code lives under pkg/serve/ and cmd/y-cluster/serve.go — no changes to yconverge, kustomize, or provision packages. Initial release validates the y-kustomize-local backend against a GitHub release artifact on linux and macos. Co-Authored-By: Claude Opus 4.7 (1M context) From c8a5f19f1de06860d174ca705566243185241d4d Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 24 Apr 2026 12:56:48 +0200 Subject: [PATCH 26/43] docs: plan for y-cluster serve feature -- answers From 4299e57c979e58e59d6151c14dd6ecec88d2c6f2 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 11:17:26 +0000 Subject: [PATCH 27/43] test(serve): failing e2e + y-kustomize-bases fixture Phase 0 per SERVE_PLAN.md -- e2e harness lands before runtime code so the test suite drives the implementation, not the other way round. Tests fail with 'unknown command \"serve\"' until pkg/serve exists. Fixture mirrors the ystack y-converge-checks-dag two-base layout: one y-cluster-serve.yaml pointing at two sources, each with its own y-kustomize-bases/{group}/{name}/ tree. A second fixture prepares a duplicate-route scenario for the scan-level error path. Co-Authored-By: Claude Opus 4.7 (1M context) --- e2e/serve_test.go | 313 ++++++++++++++++++ .../blobs/setup-bucket-job/values.yaml | 1 + .../blobs/setup-bucket-job/values.yaml | 1 + .../config/y-cluster-serve.yaml | 6 + .../base-for-annotations.yaml | 14 + .../blobs/setup-bucket-job/values.yaml | 2 + .../setup-topic-job/base-for-annotations.yaml | 14 + 7 files changed, 351 insertions(+) create mode 100644 e2e/serve_test.go create mode 100644 testdata/serve-ykustomize-local-dup/sources-x/y-kustomize-bases/blobs/setup-bucket-job/values.yaml create mode 100644 testdata/serve-ykustomize-local-dup/sources-y/y-kustomize-bases/blobs/setup-bucket-job/values.yaml create mode 100644 testdata/serve-ykustomize-local/config/y-cluster-serve.yaml create mode 100644 testdata/serve-ykustomize-local/sources/a/y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml create mode 100644 testdata/serve-ykustomize-local/sources/a/y-kustomize-bases/blobs/setup-bucket-job/values.yaml create mode 100644 testdata/serve-ykustomize-local/sources/b/y-kustomize-bases/kafka/setup-topic-job/base-for-annotations.yaml diff --git a/e2e/serve_test.go b/e2e/serve_test.go new file mode 100644 index 0000000..ea5547d --- /dev/null +++ b/e2e/serve_test.go @@ -0,0 +1,313 @@ +//go:build e2e + +// Package e2e tests y-cluster serve against the built binary. +// +// Fixture layout mirrors the ystack y-converge-checks-dag two-base pattern: +// a single y-cluster-serve.yaml pointing to two sources, each with a +// y-kustomize-bases/{group}/{name}/ tree. +// +// This test drives the CLI as an end-user would: build the binary, run +// `serve ensure`, hit the endpoints, then `serve stop`. It is the same +// path .github/workflows/e2e-release.yaml will take against a released +// archive. +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +var ( + serveBinaryOnce sync.Once + serveBinaryPath string + serveBinaryErr error +) + +// buildServeBinary compiles cmd/y-cluster once per test process. +func buildServeBinary(t *testing.T) string { + t.Helper() + serveBinaryOnce.Do(func() { + dir, err := os.MkdirTemp("", "y-cluster-serve-bin-*") + if err != nil { + serveBinaryErr = err + return + } + out := filepath.Join(dir, "y-cluster") + cmd := exec.Command("go", "build", "-o", out, "./cmd/y-cluster") + cmd.Dir = ".." + if outb, err := cmd.CombinedOutput(); err != nil { + serveBinaryErr = fmt.Errorf("build: %s: %w", outb, err) + return + } + serveBinaryPath = out + }) + if serveBinaryErr != nil { + t.Fatal(serveBinaryErr) + } + return serveBinaryPath +} + +// freePort returns a TCP port that is free right now. Caller races any +// other process grabbing it, but the window is tiny. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + _ = l.Close() + return port +} + +// prepareFixture copies testdata/serve-ykustomize-local/ into a temp dir +// and substitutes __PORT__ in y-cluster-serve.yaml. Returns the absolute +// path of the prepared config directory. +func prepareFixture(t *testing.T, port int) string { + t.Helper() + src, err := filepath.Abs("../testdata/serve-ykustomize-local") + if err != nil { + t.Fatal(err) + } + dst := t.TempDir() + if err := copyTree(src, dst); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dst, "config", "y-cluster-serve.yaml") + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + data = []byte(strings.ReplaceAll(string(data), "__PORT__", fmt.Sprintf("%d", port))) + if err := os.WriteFile(cfgPath, data, 0o644); err != nil { + t.Fatal(err) + } + return filepath.Join(dst, "config") +} + +func copyTree(src, dst string) error { + return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(src, path) + if err != nil { + return err + } + target := filepath.Join(dst, rel) + if info.IsDir() { + return os.MkdirAll(target, 0o755) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(target, data, info.Mode().Perm()) + }) +} + +func runServe(t *testing.T, bin, stateDir string, args ...string) ([]byte, error) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Env = append(os.Environ(), "Y_CLUSTER_SERVE_STATE_DIR="+stateDir) + return cmd.CombinedOutput() +} + +func TestServe_EnsureRoundtrip(t *testing.T) { + bin := buildServeBinary(t) + port := freePort(t) + cfgDir := prepareFixture(t, port) + stateDir := t.TempDir() + + // 1. ensure → daemon starts, /health 200 on the configured port + if out, err := runServe(t, bin, stateDir, "serve", "ensure", "-c", cfgDir); err != nil { + t.Fatalf("ensure: %v\n%s", err, out) + } + t.Cleanup(func() { + _, _ = runServe(t, bin, stateDir, "serve", "stop") + }) + + if err := httpGetStatus(fmt.Sprintf("http://127.0.0.1:%d/health", port)); err != nil { + t.Fatalf("health: %v", err) + } + + // 2. known routes from each source are served + body, hdr, err := httpGet(fmt.Sprintf("http://127.0.0.1:%d/v1/blobs/setup-bucket-job/base-for-annotations.yaml", port)) + if err != nil { + t.Fatalf("GET blobs: %v", err) + } + if !strings.Contains(string(body), "setup-bucket-job") { + t.Fatalf("body missing marker: %q", body) + } + if ct := hdr.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { + t.Fatalf("content-type: got %q, want application/yaml*", ct) + } + etag := hdr.Get("ETag") + if etag == "" { + t.Fatal("missing ETag") + } + if cc := hdr.Get("Cache-Control"); !strings.Contains(cc, "no-cache") { + t.Fatalf("cache-control: got %q, want no-cache", cc) + } + + // 3. conditional GET with matching ETag → 304 + code, err := httpGetWithETag(fmt.Sprintf("http://127.0.0.1:%d/v1/blobs/setup-bucket-job/base-for-annotations.yaml", port), etag) + if err != nil { + t.Fatalf("conditional GET: %v", err) + } + if code != http.StatusNotModified { + t.Fatalf("conditional GET: got %d, want 304", code) + } + + // 4. other source is merged under the same /v1/ namespace + if _, _, err := httpGet(fmt.Sprintf("http://127.0.0.1:%d/v1/kafka/setup-topic-job/base-for-annotations.yaml", port)); err != nil { + t.Fatalf("GET kafka: %v", err) + } + + // 5. openapi snapshot lists every served path + body, _, err = httpGet(fmt.Sprintf("http://127.0.0.1:%d/openapi.yaml", port)) + if err != nil { + t.Fatalf("openapi: %v", err) + } + for _, want := range []string{ + "/v1/blobs/setup-bucket-job/base-for-annotations.yaml", + "/v1/blobs/setup-bucket-job/values.yaml", + "/v1/kafka/setup-topic-job/base-for-annotations.yaml", + } { + if !strings.Contains(string(body), want) { + t.Fatalf("openapi missing %s", want) + } + } + + // 6. ensure a second time → no-op (pid unchanged) + pidBefore, err := os.ReadFile(filepath.Join(stateDir, "serve.pid")) + if err != nil { + t.Fatalf("read pid: %v", err) + } + if out, err := runServe(t, bin, stateDir, "serve", "ensure", "-c", cfgDir); err != nil { + t.Fatalf("ensure#2: %v\n%s", err, out) + } + pidAfter, err := os.ReadFile(filepath.Join(stateDir, "serve.pid")) + if err != nil { + t.Fatalf("read pid: %v", err) + } + if string(pidBefore) != string(pidAfter) { + t.Fatalf("daemon restarted on identical ensure: %s → %s", pidBefore, pidAfter) + } + + // 7. stop → pidfile gone, /health errors + if out, err := runServe(t, bin, stateDir, "serve", "stop"); err != nil { + t.Fatalf("stop: %v\n%s", err, out) + } + if _, err := os.Stat(filepath.Join(stateDir, "serve.pid")); !os.IsNotExist(err) { + t.Fatalf("pidfile should be gone, err=%v", err) + } + + // 8. stop is idempotent + if out, err := runServe(t, bin, stateDir, "serve", "stop"); err != nil { + t.Fatalf("stop#2: %v\n%s", err, out) + } +} + +func TestServe_LogsSubcommand(t *testing.T) { + bin := buildServeBinary(t) + port := freePort(t) + cfgDir := prepareFixture(t, port) + stateDir := t.TempDir() + + if out, err := runServe(t, bin, stateDir, "serve", "ensure", "-c", cfgDir); err != nil { + t.Fatalf("ensure: %v\n%s", err, out) + } + t.Cleanup(func() { + _, _ = runServe(t, bin, stateDir, "serve", "stop") + }) + + // Trigger at least one log line + _, _, _ = httpGet(fmt.Sprintf("http://127.0.0.1:%d/health", port)) + + out, err := runServe(t, bin, stateDir, "serve", "logs") + if err != nil { + t.Fatalf("logs: %v\n%s", err, out) + } + // Background daemon uses JSON logging per Q-S1. Expect valid JSON lines. + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if line == "" { + continue + } + var j map[string]any + if err := json.Unmarshal([]byte(line), &j); err != nil { + t.Fatalf("log line not JSON: %q: %v", line, err) + } + } +} + +func httpGet(url string) ([]byte, http.Header, error) { + resp, err := retryGET(url) + if err != nil { + return nil, nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, err + } + if resp.StatusCode != http.StatusOK { + return nil, nil, fmt.Errorf("status %d: %s", resp.StatusCode, body) + } + return body, resp.Header, nil +} + +func httpGetStatus(url string) error { + resp, err := retryGET(url) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("status %d", resp.StatusCode) + } + return nil +} + +func httpGetWithETag(url, etag string) (int, error) { + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + return 0, err + } + req.Header.Set("If-None-Match", etag) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, err + } + resp.Body.Close() + return resp.StatusCode, nil +} + +// retryGET tolerates the short window after `ensure` returns where the +// listener might still be binding. Ensure itself is supposed to wait for +// /health, but tests should not race on restart. +func retryGET(url string) (*http.Response, error) { + var last error + for i := 0; i < 40; i++ { + resp, err := http.Get(url) + if err == nil { + return resp, nil + } + last = err + time.Sleep(50 * time.Millisecond) + } + return nil, last +} diff --git a/testdata/serve-ykustomize-local-dup/sources-x/y-kustomize-bases/blobs/setup-bucket-job/values.yaml b/testdata/serve-ykustomize-local-dup/sources-x/y-kustomize-bases/blobs/setup-bucket-job/values.yaml new file mode 100644 index 0000000..8b1fbbf --- /dev/null +++ b/testdata/serve-ykustomize-local-dup/sources-x/y-kustomize-bases/blobs/setup-bucket-job/values.yaml @@ -0,0 +1 @@ +origin: x diff --git a/testdata/serve-ykustomize-local-dup/sources-y/y-kustomize-bases/blobs/setup-bucket-job/values.yaml b/testdata/serve-ykustomize-local-dup/sources-y/y-kustomize-bases/blobs/setup-bucket-job/values.yaml new file mode 100644 index 0000000..d8e1d72 --- /dev/null +++ b/testdata/serve-ykustomize-local-dup/sources-y/y-kustomize-bases/blobs/setup-bucket-job/values.yaml @@ -0,0 +1 @@ +origin: y diff --git a/testdata/serve-ykustomize-local/config/y-cluster-serve.yaml b/testdata/serve-ykustomize-local/config/y-cluster-serve.yaml new file mode 100644 index 0000000..f675afe --- /dev/null +++ b/testdata/serve-ykustomize-local/config/y-cluster-serve.yaml @@ -0,0 +1,6 @@ +# yaml-language-server: $schema=../../../pkg/serve/schema/y-cluster-serve.schema.json +port: __PORT__ +type: y-kustomize-local +sources: +- dir: ../sources/a +- dir: ../sources/b diff --git a/testdata/serve-ykustomize-local/sources/a/y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml b/testdata/serve-ykustomize-local/sources/a/y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml new file mode 100644 index 0000000..bdc89cc --- /dev/null +++ b/testdata/serve-ykustomize-local/sources/a/y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml @@ -0,0 +1,14 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: setup-bucket-job + annotations: + y-kustomize/base: blobs/setup-bucket-job +spec: + template: + spec: + restartPolicy: Never + containers: + - name: setup + image: busybox + command: ["true"] diff --git a/testdata/serve-ykustomize-local/sources/a/y-kustomize-bases/blobs/setup-bucket-job/values.yaml b/testdata/serve-ykustomize-local/sources/a/y-kustomize-bases/blobs/setup-bucket-job/values.yaml new file mode 100644 index 0000000..9622c3e --- /dev/null +++ b/testdata/serve-ykustomize-local/sources/a/y-kustomize-bases/blobs/setup-bucket-job/values.yaml @@ -0,0 +1,2 @@ +bucket: builds +region: eu-north-1 diff --git a/testdata/serve-ykustomize-local/sources/b/y-kustomize-bases/kafka/setup-topic-job/base-for-annotations.yaml b/testdata/serve-ykustomize-local/sources/b/y-kustomize-bases/kafka/setup-topic-job/base-for-annotations.yaml new file mode 100644 index 0000000..1ac00d0 --- /dev/null +++ b/testdata/serve-ykustomize-local/sources/b/y-kustomize-bases/kafka/setup-topic-job/base-for-annotations.yaml @@ -0,0 +1,14 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: setup-topic-job + annotations: + y-kustomize/base: kafka/setup-topic-job +spec: + template: + spec: + restartPolicy: Never + containers: + - name: setup + image: busybox + command: ["true"] From 1e17d1dff0a9057e6fab6ffee1c5f9a20ddc1a11 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 11:17:42 +0000 Subject: [PATCH 28/43] feat(serve): y-cluster serve subcommand with y-kustomize-local backend Implements SERVE_FEATURE.md initial scope per SERVE_PLAN.md. All new Go code is confined to pkg/serve/ and cmd/y-cluster/serve.go, honoring the 'minor part of this tool' constraint -- no changes to yconverge, kustomize, or provision packages. pkg/serve (new): - config.go + schema: y-cluster-serve.yaml loader, strict YAML, validation, deterministic digest for `ensure` comparison - state.go: per-OS state dir (XDG / Library / LocalAppData), pidfile - http.go: weak-ETag via FNV-1a, Cache-Control no-cache force-revalidate, If-None-Match 304, yaml MIME override (application/yaml per Q-S3) - openapi.go: OpenAPI 3.1 snapshot per port, served at /openapi.yaml - health.go: /health JSON endpoint per port - ykustomizelocal.go: scan y-kustomize-bases/{group}/{name}/{file}, error on duplicate routes across sources, GET/HEAD handler - serve.go: Run / Ensure / Stop / Logs public API; refuses UID 0; --foreground uses console zap, background uses JSON zap (Q-S1); Ensure waits for /health on every port before returning (Q-S2) - process.go + spawn_unix.go: setsid-based re-exec for background, SIGTERM/SIGINT graceful shutdown with 10s deadline - static.go: schema placeholder only; runtime not in first release cmd/y-cluster/serve.go (new): - `serve`, `serve ensure`, `serve stop`, `serve logs` - Thin cobra adapter; all logic lives in pkg/serve Unit tests cover config, state, http middleware, health, y-kustomize backend, openapi snapshot, and the Run/Ensure/Stop/Logs flow via an injected spawnFn that runs the daemon in-process. The background re-exec path itself is covered by the e2e test. The Phase 0 e2e test now passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- cmd/y-cluster/main.go | 1 + cmd/y-cluster/serve.go | 107 +++++ cmd/y-cluster/serve_test.go | 76 ++++ pkg/serve/config.go | 186 +++++++++ pkg/serve/config_test.go | 185 +++++++++ pkg/serve/health.go | 32 ++ pkg/serve/health_test.go | 71 ++++ pkg/serve/http.go | 94 +++++ pkg/serve/http_test.go | 177 +++++++++ pkg/serve/openapi.go | 79 ++++ pkg/serve/openapi_test.go | 87 +++++ pkg/serve/process.go | 201 ++++++++++ pkg/serve/schema/y-cluster-serve.schema.json | 57 +++ pkg/serve/serve.go | 307 +++++++++++++++ pkg/serve/serve_test.go | 387 +++++++++++++++++++ pkg/serve/spawn_other.go | 11 + pkg/serve/spawn_unix.go | 48 +++ pkg/serve/state.go | 112 ++++++ pkg/serve/state_test.go | 112 ++++++ pkg/serve/static.go | 7 + pkg/serve/ykustomizelocal.go | 159 ++++++++ pkg/serve/ykustomizelocal_test.go | 233 +++++++++++ 22 files changed, 2729 insertions(+) create mode 100644 cmd/y-cluster/serve.go create mode 100644 cmd/y-cluster/serve_test.go create mode 100644 pkg/serve/config.go create mode 100644 pkg/serve/config_test.go create mode 100644 pkg/serve/health.go create mode 100644 pkg/serve/health_test.go create mode 100644 pkg/serve/http.go create mode 100644 pkg/serve/http_test.go create mode 100644 pkg/serve/openapi.go create mode 100644 pkg/serve/openapi_test.go create mode 100644 pkg/serve/process.go create mode 100644 pkg/serve/schema/y-cluster-serve.schema.json create mode 100644 pkg/serve/serve.go create mode 100644 pkg/serve/serve_test.go create mode 100644 pkg/serve/spawn_other.go create mode 100644 pkg/serve/spawn_unix.go create mode 100644 pkg/serve/state.go create mode 100644 pkg/serve/state_test.go create mode 100644 pkg/serve/static.go create mode 100644 pkg/serve/ykustomizelocal.go create mode 100644 pkg/serve/ykustomizelocal_test.go diff --git a/cmd/y-cluster/main.go b/cmd/y-cluster/main.go index 6317289..0d61332 100644 --- a/cmd/y-cluster/main.go +++ b/cmd/y-cluster/main.go @@ -55,6 +55,7 @@ func rootCmd() *cobra.Command { root.AddCommand(teardownCmd()) root.AddCommand(exportCmd()) root.AddCommand(importCmd()) + root.AddCommand(serveCmd()) return root } diff --git a/cmd/y-cluster/serve.go b/cmd/y-cluster/serve.go new file mode 100644 index 0000000..1ddee39 --- /dev/null +++ b/cmd/y-cluster/serve.go @@ -0,0 +1,107 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Yolean/y-cluster/pkg/serve" +) + +// serveCmd wires the `y-cluster serve` subcommands. The CLI is a thin +// adapter — every action delegates to pkg/serve. +func serveCmd() *cobra.Command { + var ( + configDirs []string + foreground bool + stateDir string + ) + + cmd := &cobra.Command{ + Use: "serve", + Short: "HTTP server for config assets (y-kustomize bases, static dirs)", + Long: `y-cluster serve brings up a lightweight HTTP server for config +assets, one port per -c config directory. Default operation is in the +background; --foreground keeps the server attached to the current shell.`, + RunE: func(cmd *cobra.Command, args []string) error { + return serve.Run(cmd.Context(), serve.Options{ + ConfigDirs: configDirs, + Foreground: foreground, + StateDir: stateDir, + }) + }, + } + cmd.Flags().StringArrayVarP(&configDirs, "config", "c", nil, "config directory (repeatable)") + cmd.Flags().BoolVar(&foreground, "foreground", false, "run in the foreground instead of detaching") + cmd.Flags().StringVar(&stateDir, "state-dir", "", "override the per-user state directory") + if err := cmd.MarkFlagRequired("config"); err != nil { + panic(err) + } + + cmd.AddCommand(serveEnsureCmd()) + cmd.AddCommand(serveStopCmd()) + cmd.AddCommand(serveLogsCmd()) + return cmd +} + +func serveEnsureCmd() *cobra.Command { + var ( + configDirs []string + stateDir string + ) + cmd := &cobra.Command{ + Use: "ensure", + Short: "Start the serve daemon if it is not running or the config has changed", + RunE: func(cmd *cobra.Command, args []string) error { + started, err := serve.Ensure(cmd.Context(), serve.Options{ + ConfigDirs: configDirs, + StateDir: stateDir, + }) + if err != nil { + return err + } + if started { + fmt.Fprintln(cmd.ErrOrStderr(), "y-cluster serve started") + } else { + fmt.Fprintln(cmd.ErrOrStderr(), "y-cluster serve already running") + } + return nil + }, + } + cmd.Flags().StringArrayVarP(&configDirs, "config", "c", nil, "config directory (repeatable)") + cmd.Flags().StringVar(&stateDir, "state-dir", "", "override the per-user state directory") + if err := cmd.MarkFlagRequired("config"); err != nil { + panic(err) + } + return cmd +} + +func serveStopCmd() *cobra.Command { + var stateDir string + cmd := &cobra.Command{ + Use: "stop", + Short: "Stop the running serve daemon", + RunE: func(cmd *cobra.Command, args []string) error { + return serve.Stop(cmd.Context(), stateDir) + }, + } + cmd.Flags().StringVar(&stateDir, "state-dir", "", "override the per-user state directory") + return cmd +} + +func serveLogsCmd() *cobra.Command { + var ( + stateDir string + follow bool + ) + cmd := &cobra.Command{ + Use: "logs", + Short: "Print the serve daemon log file", + RunE: func(cmd *cobra.Command, args []string) error { + return serve.Logs(cmd.Context(), cmd.OutOrStdout(), stateDir, follow) + }, + } + cmd.Flags().StringVar(&stateDir, "state-dir", "", "override the per-user state directory") + cmd.Flags().BoolVarP(&follow, "follow", "f", false, "follow the log file like `tail -f`") + return cmd +} diff --git a/cmd/y-cluster/serve_test.go b/cmd/y-cluster/serve_test.go new file mode 100644 index 0000000..c99d6fe --- /dev/null +++ b/cmd/y-cluster/serve_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestServeCmd_RequiresConfig(t *testing.T) { + cmd := serveCmd() + cmd.SetArgs([]string{}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "required flag") { + t.Fatalf("want required-flag error, got %v", err) + } +} + +func TestServeEnsureCmd_RequiresConfig(t *testing.T) { + cmd := serveEnsureCmd() + cmd.SetArgs([]string{}) + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "required flag") { + t.Fatalf("want required-flag error, got %v", err) + } +} + +func TestServeStopCmd_DefaultsOK(t *testing.T) { + cmd := serveStopCmd() + // No --state-dir provided → falls through to DefaultStateDir(). + // We don't want to touch the real HOME, so just assert flag parsing. + cmd.SetArgs([]string{"--help"}) + var out bytes.Buffer + cmd.SetOut(&out) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "state-dir") { + t.Fatalf("help output missing flag: %s", out.String()) + } +} + +func TestServeLogsCmd_FlagsParse(t *testing.T) { + cmd := serveLogsCmd() + cmd.SetArgs([]string{"--follow", "--state-dir", "/tmp/xy-cluster-nonexistent"}) + // We don't run it; reading the log file of a non-existent dir would + // create the dir due to ResolveStatePaths. Just check flag parsing + // via --help instead. + cmd.SetArgs([]string{"--help"}) + var out bytes.Buffer + cmd.SetOut(&out) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "--follow") { + t.Fatalf("help missing follow flag: %s", out.String()) + } +} + +func TestServeCmd_HasSubcommands(t *testing.T) { + cmd := serveCmd() + var found = map[string]bool{} + for _, c := range cmd.Commands() { + found[c.Name()] = true + } + for _, want := range []string{"ensure", "stop", "logs"} { + if !found[want] { + t.Fatalf("missing subcommand: %s", want) + } + } +} diff --git a/pkg/serve/config.go b/pkg/serve/config.go new file mode 100644 index 0000000..08a3b09 --- /dev/null +++ b/pkg/serve/config.go @@ -0,0 +1,186 @@ +// Package serve implements `y-cluster serve`: a lightweight HTTP server for +// config assets (y-kustomize bases today; static dirs later). See +// SERVE_FEATURE.md for scope and SERVE_PLAN.md for design. +package serve + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + + "sigs.k8s.io/yaml" +) + +// ConfigFilename is the name of the YAML file every `-c` dir must contain. +const ConfigFilename = "y-cluster-serve.yaml" + +// BackendType is the `type:` field in y-cluster-serve.yaml. +type BackendType string + +const ( + TypeYKustomizeLocal BackendType = "y-kustomize-local" + TypeStatic BackendType = "static" +) + +// Config is the parsed y-cluster-serve.yaml plus the directory it came from. +type Config struct { + // Dir is the absolute path of the `-c` directory; source paths + // declared relative in YAML resolve against this. + Dir string `json:"dir"` + + Port int `json:"port" yaml:"port"` + Type BackendType `json:"type" yaml:"type"` + Static *StaticConfig `json:"static,omitempty" yaml:"static,omitempty"` + Sources []YKustomizeLocalSource `json:"sources,omitempty" yaml:"sources,omitempty"` +} + +// StaticConfig is declared so the schema round-trips, but the runtime +// backend is not implemented in the first release. +type StaticConfig struct { + Dir string `json:"dir" yaml:"dir"` + Root string `json:"root" yaml:"root"` + YAMLToJSON bool `json:"yamlToJson,omitempty" yaml:"yamlToJson,omitempty"` + DirTrailingSlash string `json:"dirTrailingSlash,omitempty" yaml:"dirTrailingSlash,omitempty"` +} + +// YKustomizeLocalSource is one entry in `sources:` for type y-kustomize-local. +type YKustomizeLocalSource struct { + Dir string `json:"dir" yaml:"dir"` +} + +// LoadConfigDir reads `{dir}/y-cluster-serve.yaml`, validates it, and +// returns the parsed config with Dir set to the absolute of `dir`. +func LoadConfigDir(dir string) (*Config, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return nil, fmt.Errorf("resolve %s: %w", dir, err) + } + info, err := os.Stat(abs) + if err != nil { + return nil, fmt.Errorf("config dir %s: %w", dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("config path is not a directory: %s", abs) + } + path := filepath.Join(abs, ConfigFilename) + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + var c Config + if err := yaml.UnmarshalStrict(data, &c); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) + } + c.Dir = abs + if err := c.validate(); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + return &c, nil +} + +// LoadConfigDirs loads every `-c` dir, deduplicates by absolute path, and +// verifies that no two configs share a port. +func LoadConfigDirs(dirs []string) ([]*Config, error) { + if len(dirs) == 0 { + return nil, fmt.Errorf("at least one -c is required") + } + seen := make(map[string]bool, len(dirs)) + ports := make(map[int]string, len(dirs)) + out := make([]*Config, 0, len(dirs)) + for _, d := range dirs { + c, err := LoadConfigDir(d) + if err != nil { + return nil, err + } + if seen[c.Dir] { + continue // same `-c` passed twice is not an error + } + seen[c.Dir] = true + if prev, dup := ports[c.Port]; dup { + return nil, fmt.Errorf("port %d declared by both %s and %s", c.Port, prev, c.Dir) + } + ports[c.Port] = c.Dir + out = append(out, c) + } + sort.Slice(out, func(i, j int) bool { return out[i].Port < out[j].Port }) + return out, nil +} + +// ResolvedSources returns source dirs as absolute paths, resolved against +// Config.Dir. Only meaningful for y-kustomize-local configs. +func (c *Config) ResolvedSources() []string { + out := make([]string, 0, len(c.Sources)) + for _, s := range c.Sources { + p := s.Dir + if !filepath.IsAbs(p) { + p = filepath.Join(c.Dir, p) + } + out = append(out, filepath.Clean(p)) + } + return out +} + +// Digest returns a stable SHA-256 over the normalized config set. `ensure` +// compares this against the digest stored alongside the running daemon. +func Digest(cfgs []*Config) string { + norm := make([]Config, 0, len(cfgs)) + for _, c := range cfgs { + cp := *c + if cp.Type == TypeYKustomizeLocal { + srcs := c.ResolvedSources() + cp.Sources = make([]YKustomizeLocalSource, len(srcs)) + for i, s := range srcs { + cp.Sources[i] = YKustomizeLocalSource{Dir: s} + } + } + norm = append(norm, cp) + } + sort.Slice(norm, func(i, j int) bool { return norm[i].Port < norm[j].Port }) + b, _ := json.Marshal(norm) + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +func (c *Config) validate() error { + if c.Port < 1 || c.Port > 65535 { + return fmt.Errorf("port %d out of range 1-65535", c.Port) + } + switch c.Type { + case TypeYKustomizeLocal: + if len(c.Sources) == 0 { + return fmt.Errorf("type %s requires at least one source", c.Type) + } + for i, s := range c.Sources { + if s.Dir == "" { + return fmt.Errorf("sources[%d].dir is empty", i) + } + } + if c.Static != nil { + return fmt.Errorf("static config not allowed for type %s", c.Type) + } + case TypeStatic: + if c.Static == nil { + return fmt.Errorf("type %s requires static block", c.Type) + } + if c.Static.Dir == "" { + return fmt.Errorf("static.dir is empty") + } + if len(c.Sources) != 0 { + return fmt.Errorf("sources not allowed for type %s", c.Type) + } + switch c.Static.DirTrailingSlash { + case "", "redirect": + default: + return fmt.Errorf("static.dirTrailingSlash %q is not a known mode", c.Static.DirTrailingSlash) + } + case "": + return fmt.Errorf("type is required") + default: + return fmt.Errorf("unknown type %q", c.Type) + } + return nil +} diff --git a/pkg/serve/config_test.go b/pkg/serve/config_test.go new file mode 100644 index 0000000..efcbf41 --- /dev/null +++ b/pkg/serve/config_test.go @@ -0,0 +1,185 @@ +package serve + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func writeConfig(t *testing.T, dir, body string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ConfigFilename), []byte(body), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestLoadConfigDir_YKustomizeLocal(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, ` +port: 12345 +type: y-kustomize-local +sources: +- dir: ./a +- dir: /abs/b +`) + c, err := LoadConfigDir(dir) + if err != nil { + t.Fatal(err) + } + if c.Port != 12345 || c.Type != TypeYKustomizeLocal { + t.Fatalf("got %+v", c) + } + if len(c.Sources) != 2 { + t.Fatalf("sources: %v", c.Sources) + } + got := c.ResolvedSources() + if got[0] != filepath.Join(c.Dir, "a") { + t.Fatalf("relative not resolved against Dir: %s", got[0]) + } + if got[1] != "/abs/b" { + t.Fatalf("absolute source mangled: %s", got[1]) + } +} + +func TestLoadConfigDir_Static(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, ` +port: 8080 +type: static +static: + dir: ./files + root: /assets + yamlToJson: true + dirTrailingSlash: redirect +`) + c, err := LoadConfigDir(dir) + if err != nil { + t.Fatal(err) + } + if c.Static == nil || !c.Static.YAMLToJSON || c.Static.DirTrailingSlash != "redirect" { + t.Fatalf("static: %+v", c.Static) + } +} + +func TestLoadConfigDir_Errors(t *testing.T) { + cases := []struct { + name, body, wantSub string + }{ + {"missing-port", "type: y-kustomize-local\nsources: [{dir: ./a}]\n", "port 0"}, + {"port-out-of-range", "port: 70000\ntype: y-kustomize-local\nsources: [{dir: ./a}]\n", "out of range"}, + {"missing-type", "port: 1\n", "type is required"}, + {"unknown-type", "port: 1\ntype: bogus\n", "unknown type"}, + {"unknown-field", "port: 1\ntype: y-kustomize-local\nsources: [{dir: ./a}]\nextra: x\n", "unknown field"}, + {"ykl-no-sources", "port: 1\ntype: y-kustomize-local\n", "at least one source"}, + {"ykl-empty-source-dir", "port: 1\ntype: y-kustomize-local\nsources: [{dir: ''}]\n", "dir is empty"}, + {"ykl-with-static", "port: 1\ntype: y-kustomize-local\nsources: [{dir: ./a}]\nstatic: {dir: ./x}\n", "static config not allowed"}, + {"static-no-block", "port: 1\ntype: static\n", "requires static block"}, + {"static-empty-dir", "port: 1\ntype: static\nstatic: {dir: ''}\n", "static.dir is empty"}, + {"static-with-sources", "port: 1\ntype: static\nstatic: {dir: ./a}\nsources: [{dir: ./b}]\n", "sources not allowed"}, + {"static-bad-trailing-slash", "port: 1\ntype: static\nstatic: {dir: ./a, dirTrailingSlash: strip}\n", "dirTrailingSlash"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + writeConfig(t, dir, tc.body) + _, err := LoadConfigDir(dir) + if err == nil || !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("want error containing %q, got %v", tc.wantSub, err) + } + }) + } +} + +func TestLoadConfigDir_MissingDir(t *testing.T) { + if _, err := LoadConfigDir(filepath.Join(t.TempDir(), "nope")); err == nil { + t.Fatal("want error") + } +} + +func TestLoadConfigDir_NotADirectory(t *testing.T) { + f := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := LoadConfigDir(f); err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("want not-a-directory error, got %v", err) + } +} + +func TestLoadConfigDir_MissingFile(t *testing.T) { + dir := t.TempDir() + if _, err := LoadConfigDir(dir); err == nil { + t.Fatal("want error for missing file") + } +} + +func TestLoadConfigDirs_DuplicatePort(t *testing.T) { + a := t.TempDir() + b := t.TempDir() + writeConfig(t, a, "port: 9000\ntype: y-kustomize-local\nsources: [{dir: ./a}]\n") + writeConfig(t, b, "port: 9000\ntype: y-kustomize-local\nsources: [{dir: ./a}]\n") + if _, err := LoadConfigDirs([]string{a, b}); err == nil || !strings.Contains(err.Error(), "port 9000") { + t.Fatalf("want duplicate-port error, got %v", err) + } +} + +func TestLoadConfigDirs_DedupSameDir(t *testing.T) { + a := t.TempDir() + writeConfig(t, a, "port: 9001\ntype: y-kustomize-local\nsources: [{dir: ./a}]\n") + cfgs, err := LoadConfigDirs([]string{a, a}) + if err != nil { + t.Fatal(err) + } + if len(cfgs) != 1 { + t.Fatalf("dedup: %d", len(cfgs)) + } +} + +func TestLoadConfigDirs_Empty(t *testing.T) { + if _, err := LoadConfigDirs(nil); err == nil { + t.Fatal("want error for empty dirs") + } +} + +func TestLoadConfigDirs_SortsByPort(t *testing.T) { + a := t.TempDir() + b := t.TempDir() + writeConfig(t, a, "port: 9003\ntype: y-kustomize-local\nsources: [{dir: ./a}]\n") + writeConfig(t, b, "port: 9002\ntype: y-kustomize-local\nsources: [{dir: ./a}]\n") + cfgs, err := LoadConfigDirs([]string{a, b}) + if err != nil { + t.Fatal(err) + } + if cfgs[0].Port != 9002 || cfgs[1].Port != 9003 { + t.Fatalf("not sorted: %d %d", cfgs[0].Port, cfgs[1].Port) + } +} + +func TestDigest_Stable(t *testing.T) { + a := t.TempDir() + b := t.TempDir() + writeConfig(t, a, "port: 8000\ntype: y-kustomize-local\nsources: [{dir: ./a}]\n") + writeConfig(t, b, "port: 8001\ntype: y-kustomize-local\nsources: [{dir: ./a}]\n") + cfgs1, _ := LoadConfigDirs([]string{a, b}) + cfgs2, _ := LoadConfigDirs([]string{b, a}) // reversed + if Digest(cfgs1) != Digest(cfgs2) { + t.Fatal("digest depends on order") + } +} + +func TestDigest_ChangesWhenSourceChanges(t *testing.T) { + a := t.TempDir() + writeConfig(t, a, "port: 8000\ntype: y-kustomize-local\nsources: [{dir: ./a}]\n") + cfgs1, _ := LoadConfigDirs([]string{a}) + first := Digest(cfgs1) + + writeConfig(t, a, "port: 8000\ntype: y-kustomize-local\nsources: [{dir: ./b}]\n") + cfgs2, _ := LoadConfigDirs([]string{a}) + if Digest(cfgs2) == first { + t.Fatal("digest did not change when sources changed") + } +} diff --git a/pkg/serve/health.go b/pkg/serve/health.go new file mode 100644 index 0000000..55bd557 --- /dev/null +++ b/pkg/serve/health.go @@ -0,0 +1,32 @@ +package serve + +import ( + "encoding/json" + "net/http" +) + +// HealthHandler returns 200 with a small JSON payload describing the +// backend. Ensure probes this on every configured port before returning. +func HealthHandler(kind BackendType, extra map[string]any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + MethodNotAllowed(w, http.MethodGet, http.MethodHead) + return + } + payload := map[string]any{ + "ok": true, + "type": string(kind), + } + for k, v := range extra { + payload[k] = v + } + body, _ := json.Marshal(payload) + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + _, _ = w.Write(body) + } +} diff --git a/pkg/serve/health_test.go b/pkg/serve/health_test.go new file mode 100644 index 0000000..56c4398 --- /dev/null +++ b/pkg/serve/health_test.go @@ -0,0 +1,71 @@ +package serve + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestHealthHandler_GET(t *testing.T) { + srv := httptest.NewServer(HealthHandler(TypeYKustomizeLocal, map[string]any{"routes": 3})) + defer srv.Close() + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("status: %d", resp.StatusCode) + } + if resp.Header.Get("Cache-Control") != "no-store" { + t.Fatalf("cache-control: %s", resp.Header.Get("Cache-Control")) + } + body, _ := io.ReadAll(resp.Body) + var got map[string]any + if err := json.Unmarshal(body, &got); err != nil { + t.Fatal(err) + } + if got["ok"] != true { + t.Fatalf("ok: %v", got) + } + if got["type"] != "y-kustomize-local" { + t.Fatalf("type: %v", got) + } + if got["routes"].(float64) != 3 { + t.Fatalf("routes: %v", got) + } +} + +func TestHealthHandler_HEAD(t *testing.T) { + srv := httptest.NewServer(HealthHandler(TypeStatic, nil)) + defer srv.Close() + req, _ := http.NewRequest(http.MethodHead, srv.URL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("status: %d", resp.StatusCode) + } + body, _ := io.ReadAll(resp.Body) + if len(body) != 0 { + t.Fatalf("HEAD body: %q", body) + } +} + +func TestHealthHandler_BadMethod(t *testing.T) { + srv := httptest.NewServer(HealthHandler(TypeStatic, nil)) + defer srv.Close() + req, _ := http.NewRequest(http.MethodPost, srv.URL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status: %d", resp.StatusCode) + } +} diff --git a/pkg/serve/http.go b/pkg/serve/http.go new file mode 100644 index 0000000..a6965eb --- /dev/null +++ b/pkg/serve/http.go @@ -0,0 +1,94 @@ +package serve + +import ( + "encoding/hex" + "hash/fnv" + "mime" + "net/http" + "path/filepath" + "strconv" + "strings" +) + +// yamlMIME is the MIME type per RFC 9512. Q-S3 confirmed. +const yamlMIME = "application/yaml" + +// mimeOverrides maps extensions the stdlib mime package does not always +// map the way we want for kustomize consumers. +var mimeOverrides = map[string]string{ + ".yaml": yamlMIME, + ".yml": yamlMIME, +} + +// DetectContentType returns the content type for a given filename. +// Falls back to application/octet-stream only when no extension match. +func DetectContentType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + if ct, ok := mimeOverrides[ext]; ok { + return ct + } + if ct := mime.TypeByExtension(ext); ct != "" { + return ct + } + return "application/octet-stream" +} + +// ComputeETag returns a weak FNV-1a 64-bit ETag. Weak because a future +// transform (yamlToJson) may produce a different representation of the +// same underlying file, and weak ETags communicate that to caches. +func ComputeETag(body []byte) string { + h := fnv.New64a() + _, _ = h.Write(body) + sum := h.Sum(nil) + return `W/"` + hex.EncodeToString(sum) + `"` +} + +// WriteAsset renders body with y-cluster-serve's standard headers: +// ETag, Cache-Control forcing revalidation, and the detected content type. +// Honors If-None-Match → 304 and supports HEAD by discarding the body +// while preserving Content-Length. +func WriteAsset(w http.ResponseWriter, r *http.Request, filename string, body []byte) { + etag := ComputeETag(body) + h := w.Header() + h.Set("ETag", etag) + h.Set("Cache-Control", "no-cache, must-revalidate") + h.Set("Content-Type", DetectContentType(filename)) + h.Set("Content-Length", strconv.Itoa(len(body))) + + if matchesETag(r.Header.Get("If-None-Match"), etag) { + w.WriteHeader(http.StatusNotModified) + return + } + if r.Method == http.MethodHead { + w.WriteHeader(http.StatusOK) + return + } + _, _ = w.Write(body) +} + +// matchesETag implements the RFC 7232 If-None-Match check for the single +// asset case. Accepts "*", and handles a comma-separated list. Weak tags +// compare only by opaque-tag equality here. +func matchesETag(header, have string) bool { + if header == "" { + return false + } + header = strings.TrimSpace(header) + if header == "*" { + return true + } + for _, part := range strings.Split(header, ",") { + part = strings.TrimSpace(part) + if part == have { + return true + } + } + return false +} + +// MethodNotAllowed writes 405 with an Allow header. Used for routes that +// exist but don't support the requested method. +func MethodNotAllowed(w http.ResponseWriter, allow ...string) { + w.Header().Set("Allow", strings.Join(allow, ", ")) + w.WriteHeader(http.StatusMethodNotAllowed) +} diff --git a/pkg/serve/http_test.go b/pkg/serve/http_test.go new file mode 100644 index 0000000..8b1412c --- /dev/null +++ b/pkg/serve/http_test.go @@ -0,0 +1,177 @@ +package serve + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestDetectContentType_YAML(t *testing.T) { + for _, ext := range []string{"foo.yaml", "bar.yml", "x.YAML"} { + if DetectContentType(ext) != yamlMIME { + t.Fatalf("%s → %s, want %s", ext, DetectContentType(ext), yamlMIME) + } + } +} + +func TestDetectContentType_Fallback(t *testing.T) { + if got := DetectContentType("noext"); got != "application/octet-stream" { + t.Fatalf("fallback: %s", got) + } + if got := DetectContentType("x.json"); !strings.HasPrefix(got, "application/json") { + t.Fatalf("json: %s", got) + } +} + +func TestComputeETag_Deterministic(t *testing.T) { + a := ComputeETag([]byte("hello")) + b := ComputeETag([]byte("hello")) + if a != b { + t.Fatalf("not deterministic: %s %s", a, b) + } + if !strings.HasPrefix(a, `W/"`) { + t.Fatalf("expected weak ETag, got %s", a) + } + if c := ComputeETag([]byte("world")); c == a { + t.Fatal("different input produced same ETag") + } +} + +func TestWriteAsset_GET(t *testing.T) { + body := []byte("key: value\n") + h := func(w http.ResponseWriter, r *http.Request) { + WriteAsset(w, r, "foo.yaml", body) + } + srv := httptest.NewServer(http.HandlerFunc(h)) + defer srv.Close() + + resp, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + got, _ := io.ReadAll(resp.Body) + if string(got) != string(body) { + t.Fatalf("body: %q", got) + } + if resp.Header.Get("ETag") == "" { + t.Fatal("missing ETag") + } + if !strings.Contains(resp.Header.Get("Cache-Control"), "no-cache") { + t.Fatalf("cache-control: %s", resp.Header.Get("Cache-Control")) + } + if ct := resp.Header.Get("Content-Type"); ct != yamlMIME { + t.Fatalf("content-type: %s", ct) + } +} + +func TestWriteAsset_ConditionalGET(t *testing.T) { + body := []byte("x") + h := func(w http.ResponseWriter, r *http.Request) { + WriteAsset(w, r, "foo.txt", body) + } + srv := httptest.NewServer(http.HandlerFunc(h)) + defer srv.Close() + + resp1, err := http.Get(srv.URL) + if err != nil { + t.Fatal(err) + } + resp1.Body.Close() + etag := resp1.Header.Get("ETag") + + req, _ := http.NewRequest(http.MethodGet, srv.URL, nil) + req.Header.Set("If-None-Match", etag) + resp2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp2.Body.Close() + if resp2.StatusCode != http.StatusNotModified { + t.Fatalf("status: %d", resp2.StatusCode) + } + + req.Header.Set("If-None-Match", "*") + resp3, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp3.Body.Close() + if resp3.StatusCode != http.StatusNotModified { + t.Fatalf("star: %d", resp3.StatusCode) + } + + req.Header.Set("If-None-Match", `W/"other", `+etag) + resp4, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp4.Body.Close() + if resp4.StatusCode != http.StatusNotModified { + t.Fatalf("list: %d", resp4.StatusCode) + } + + req.Header.Set("If-None-Match", `W/"different"`) + resp5, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp5.Body.Close() + if resp5.StatusCode != http.StatusOK { + t.Fatalf("no-match: %d", resp5.StatusCode) + } +} + +func TestWriteAsset_HEAD(t *testing.T) { + body := []byte("hello") + h := func(w http.ResponseWriter, r *http.Request) { + WriteAsset(w, r, "foo.txt", body) + } + srv := httptest.NewServer(http.HandlerFunc(h)) + defer srv.Close() + + req, _ := http.NewRequest(http.MethodHead, srv.URL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + got, _ := io.ReadAll(resp.Body) + if len(got) != 0 { + t.Fatalf("HEAD body: %q", got) + } + if resp.Header.Get("Content-Length") != "5" { + t.Fatalf("content-length: %s", resp.Header.Get("Content-Length")) + } + if resp.Header.Get("ETag") == "" { + t.Fatal("missing ETag on HEAD") + } +} + +func TestMethodNotAllowed(t *testing.T) { + h := func(w http.ResponseWriter, r *http.Request) { + MethodNotAllowed(w, http.MethodGet, http.MethodHead) + } + srv := httptest.NewServer(http.HandlerFunc(h)) + defer srv.Close() + req, _ := http.NewRequest(http.MethodPost, srv.URL, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("status: %d", resp.StatusCode) + } + if allow := resp.Header.Get("Allow"); !strings.Contains(allow, "GET") { + t.Fatalf("Allow: %s", allow) + } +} + +func TestMatchesETag_Empty(t *testing.T) { + if matchesETag("", `W/"x"`) { + t.Fatal("empty header must not match") + } +} diff --git a/pkg/serve/openapi.go b/pkg/serve/openapi.go new file mode 100644 index 0000000..c1399bf --- /dev/null +++ b/pkg/serve/openapi.go @@ -0,0 +1,79 @@ +package serve + +import ( + "bytes" + "fmt" + "net/http" + "strings" +) + +// OpenAPISpec is the minimal live spec per port. Generated at start, +// served from /openapi.yaml. Intentionally hand-rolled YAML to avoid a +// new dependency and produce byte-stable output for golden tests. +type openAPISpec struct { + Title string + Type BackendType + Version string + Routes []specRoute +} + +type specRoute struct { + Path string + ContentType string +} + +func newOpenAPISpec(title string, typ BackendType, version string, routes []specRoute) openAPISpec { + return openAPISpec{Title: title, Type: typ, Version: version, Routes: routes} +} + +// Render writes the OpenAPI 3.1 YAML to the writer. +func (s openAPISpec) Render() []byte { + var b bytes.Buffer + b.WriteString("openapi: 3.1.0\n") + b.WriteString("info:\n") + b.WriteString(fmt.Sprintf(" title: %s\n", yamlEscape(s.Title))) + b.WriteString(fmt.Sprintf(" x-type: %s\n", string(s.Type))) + b.WriteString(fmt.Sprintf(" version: %s\n", yamlEscape(s.Version))) + b.WriteString("paths:\n") + for _, r := range s.Routes { + b.WriteString(fmt.Sprintf(" %s:\n", yamlEscape(r.Path))) + b.WriteString(" get:\n") + b.WriteString(" responses:\n") + b.WriteString(" \"200\":\n") + b.WriteString(" content:\n") + b.WriteString(fmt.Sprintf(" %s: {}\n", yamlEscape(r.ContentType))) + } + return b.Bytes() +} + +// yamlEscape is enough for our domain: quote if the value contains any +// character that would otherwise start a YAML construct. +func yamlEscape(s string) string { + if s == "" { + return `""` + } + needsQuote := strings.ContainsAny(s, ":#{}[],&*!|>'\"%@`\n\t") || + strings.HasPrefix(s, "-") || + strings.HasPrefix(s, "?") || + strings.HasPrefix(s, " ") || + strings.HasSuffix(s, " ") + if !needsQuote { + return s + } + // Double-quote and escape " and \ + esc := strings.ReplaceAll(s, `\`, `\\`) + esc = strings.ReplaceAll(esc, `"`, `\"`) + return `"` + esc + `"` +} + +// OpenAPIHandler serves a pre-rendered spec. The spec is snapshotted at +// backend construction per SERVE_FEATURE.md §"Scope limitation". +func OpenAPIHandler(spec []byte) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + MethodNotAllowed(w, http.MethodGet, http.MethodHead) + return + } + WriteAsset(w, r, "openapi.yaml", spec) + } +} diff --git a/pkg/serve/openapi_test.go b/pkg/serve/openapi_test.go new file mode 100644 index 0000000..c2c71da --- /dev/null +++ b/pkg/serve/openapi_test.go @@ -0,0 +1,87 @@ +package serve + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestOpenAPISpec_Render(t *testing.T) { + s := newOpenAPISpec("y-cluster serve :8944", TypeYKustomizeLocal, "v1.2.3", []specRoute{ + {Path: "/v1/blobs/setup-bucket-job/values.yaml", ContentType: yamlMIME}, + {Path: "/v1/kafka/setup-topic-job/base-for-annotations.yaml", ContentType: yamlMIME}, + }) + got := string(s.Render()) + want := `openapi: 3.1.0 +info: + title: "y-cluster serve :8944" + x-type: y-kustomize-local + version: v1.2.3 +paths: + /v1/blobs/setup-bucket-job/values.yaml: + get: + responses: + "200": + content: + application/yaml: {} + /v1/kafka/setup-topic-job/base-for-annotations.yaml: + get: + responses: + "200": + content: + application/yaml: {} +` + if got != want { + t.Fatalf("mismatch:\n---got---\n%s\n---want---\n%s", got, want) + } +} + +func TestYamlEscape(t *testing.T) { + cases := []struct{ in, want string }{ + {"plain", "plain"}, + {"", `""`}, + {"a:b", `"a:b"`}, + {"with space", "with space"}, + {" leading", `" leading"`}, + {"trailing ", `"trailing "`}, + {"-starts-dash", `"-starts-dash"`}, + {`quote"here`, `"quote\"here"`}, + {`slash\here`, `slash\here`}, // backslash is legal in plain YAML scalars + } + for _, tc := range cases { + if got := yamlEscape(tc.in); got != tc.want { + t.Fatalf("escape(%q) = %q, want %q", tc.in, got, tc.want) + } + } +} + +func TestOpenAPIHandler(t *testing.T) { + body := []byte("openapi: 3.1.0\n") + s := httptest.NewServer(OpenAPIHandler(body)) + defer s.Close() + + resp, err := http.Get(s.URL) + if err != nil { + t.Fatal(err) + } + got, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(got) != string(body) { + t.Fatalf("body: %q", got) + } + // HEAD + req, _ := http.NewRequest(http.MethodHead, s.URL, nil) + resp2, _ := http.DefaultClient.Do(req) + resp2.Body.Close() + if resp2.StatusCode != 200 { + t.Fatalf("HEAD: %d", resp2.StatusCode) + } + // Bad method + resp3, _ := http.Post(s.URL, "text/plain", strings.NewReader("x")) + resp3.Body.Close() + if resp3.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST: %d", resp3.StatusCode) + } +} diff --git a/pkg/serve/process.go b/pkg/serve/process.go new file mode 100644 index 0000000..5ad9238 --- /dev/null +++ b/pkg/serve/process.go @@ -0,0 +1,201 @@ +package serve + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "go.uber.org/zap" +) + +// daemonEnv is set in the re-execed child to signal it should enter the +// daemon loop directly instead of re-exec'ing again. +const daemonEnv = "Y_CLUSTER_SERVE_DAEMON" + +// daemonMode reports whether the current process is the re-execed child. +func daemonMode() bool { return os.Getenv(daemonEnv) == "1" } + +// server is one listening port. +type server struct { + port int + mux *http.ServeMux + srv *http.Server +} + +// buildServers constructs the per-port handlers. Returns a startable +// slice and the list of health URLs Ensure probes after start. +func buildServers(cfgs []*Config, logger *zap.Logger) ([]*server, []string, error) { + out := make([]*server, 0, len(cfgs)) + healthURLs := make([]string, 0, len(cfgs)) + for _, c := range cfgs { + mux := http.NewServeMux() + switch c.Type { + case TypeYKustomizeLocal: + b, err := newYKustomizeLocalBackend(c) + if err != nil { + return nil, nil, fmt.Errorf("port %d: %w", c.Port, err) + } + routes := make([]specRoute, 0, len(b.Routes())) + for _, p := range b.Routes() { + routes = append(routes, specRoute{Path: p, ContentType: b.RouteContentType(p)}) + } + spec := newOpenAPISpec( + fmt.Sprintf("y-cluster serve :%d", c.Port), + TypeYKustomizeLocal, + "dev", + routes, + ).Render() + + mux.Handle("/health", HealthHandler(TypeYKustomizeLocal, map[string]any{"routes": len(b.Routes())})) + mux.Handle("/openapi.yaml", OpenAPIHandler(spec)) + mux.Handle("/v1/", b) + logger.Info("backend ready", + zap.Int("port", c.Port), + zap.String("type", string(c.Type)), + zap.Int("routes", len(b.Routes())), + ) + case TypeStatic: + return nil, nil, fmt.Errorf("port %d: type %s is declared in the schema but not implemented in this release", c.Port, c.Type) + default: + return nil, nil, fmt.Errorf("port %d: unknown type %s", c.Port, c.Type) + } + s := &server{ + port: c.Port, + mux: mux, + srv: &http.Server{ + Addr: fmt.Sprintf(":%d", c.Port), + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + }, + } + out = append(out, s) + healthURLs = append(healthURLs, fmt.Sprintf("http://127.0.0.1:%d/health", c.Port)) + } + return out, healthURLs, nil +} + +// runDaemon blocks running every server until ctx is done or a server +// exits with an unrecoverable error. Respects SIGTERM/SIGINT via the +// ctx the caller passes in. +func runDaemon(ctx context.Context, servers []*server, logger *zap.Logger) error { + var wg sync.WaitGroup + errs := make(chan error, len(servers)) + for _, s := range servers { + s := s + ln, err := net.Listen("tcp", s.srv.Addr) + if err != nil { + return fmt.Errorf("listen :%d: %w", s.port, err) + } + wg.Add(1) + go func() { + defer wg.Done() + logger.Info("listening", zap.Int("port", s.port)) + if err := s.srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + errs <- fmt.Errorf("serve :%d: %w", s.port, err) + } + }() + } + + // Wait for context cancellation or a fatal listener error. + select { + case <-ctx.Done(): + logger.Info("shutdown signal received") + case err := <-errs: + logger.Error("server exited", zap.Error(err)) + } + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + for _, s := range servers { + if err := s.srv.Shutdown(shutdownCtx); err != nil { + logger.Warn("shutdown", zap.Int("port", s.port), zap.Error(err)) + } + } + wg.Wait() + return nil +} + +// stopByPidfile reads the pidfile, SIGTERMs the process, and waits for +// it to exit. Idempotent: zero-error if the pidfile is missing or the +// process is already gone. +func stopByPidfile(paths StatePaths, timeout time.Duration) error { + pid, err := ReadPidfile(paths.Pid) + if err != nil { + // Corrupt pidfile — remove it and treat as stopped. + _ = os.Remove(paths.Pid) + return nil + } + if pid == 0 { + // No pidfile — nothing to do. + return nil + } + if !PidAlive(pid) { + _ = os.Remove(paths.Pid) + _ = os.Remove(paths.Config) + return nil + } + proc, err := os.FindProcess(pid) + if err != nil { + return fmt.Errorf("find process %d: %w", pid, err) + } + if err := proc.Signal(syscall.SIGTERM); err != nil { + return fmt.Errorf("signal %d: %w", pid, err) + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if !PidAlive(pid) { + _ = os.Remove(paths.Pid) + _ = os.Remove(paths.Config) + return nil + } + time.Sleep(100 * time.Millisecond) + } + // Escalate. + _ = proc.Signal(syscall.SIGKILL) + time.Sleep(200 * time.Millisecond) + if PidAlive(pid) { + return fmt.Errorf("pid %d did not exit after SIGKILL", pid) + } + _ = os.Remove(paths.Pid) + _ = os.Remove(paths.Config) + return nil +} + +// waitHealthy probes every /health URL until it returns 200 or timeout. +// Honors Q-S2: Ensure must not return before ports are accepting +// requests, otherwise scripts race the listener. +func waitHealthy(ctx context.Context, urls []string, timeout time.Duration) error { + client := &http.Client{Timeout: 1 * time.Second} + deadline := time.Now().Add(timeout) + for _, u := range urls { + for { + if err := ctx.Err(); err != nil { + return err + } + resp, err := client.Get(u) + if err == nil { + _ = resp.Body.Close() + if resp.StatusCode == 200 { + break + } + } + if time.Now().After(deadline) { + return fmt.Errorf("health %s not ready after %s", u, timeout) + } + time.Sleep(100 * time.Millisecond) + } + } + return nil +} + +// withSignals wraps ctx with SIGTERM/SIGINT cancellation. +func withSignals(parent context.Context) (context.Context, context.CancelFunc) { + return signal.NotifyContext(parent, syscall.SIGINT, syscall.SIGTERM) +} diff --git a/pkg/serve/schema/y-cluster-serve.schema.json b/pkg/serve/schema/y-cluster-serve.schema.json new file mode 100644 index 0000000..68aa911 --- /dev/null +++ b/pkg/serve/schema/y-cluster-serve.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/Yolean/y-cluster/HEAD/pkg/serve/schema/y-cluster-serve.schema.json", + "title": "y-cluster-serve.yaml", + "description": "Configuration for `y-cluster serve`. One file per `-c` directory.", + "type": "object", + "required": ["port", "type"], + "additionalProperties": false, + "properties": { + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "TCP port this server binds. Each `-c` config gets its own port; name-based vhosting is not supported." + }, + "type": { + "type": "string", + "enum": ["static", "y-kustomize-local"], + "description": "Backend selector." + }, + "static": { + "type": "object", + "description": "Parameters for type=static. Not implemented in the first release.", + "required": ["dir"], + "additionalProperties": false, + "properties": { + "dir": {"type": "string", "description": "Directory to serve."}, + "root": {"type": "string", "description": "Path prefix under which the dir appears in HTTP."}, + "yamlToJson": {"type": "boolean", "default": false, "description": "Transform application/yaml responses to application/json."}, + "dirTrailingSlash": {"type": "string", "enum": ["", "redirect"], "description": "Behavior when a dir path is requested without trailing slash."} + } + }, + "sources": { + "type": "array", + "description": "Parameters for type=y-kustomize-local. Each source contributes files under y-kustomize-bases/ which map to /v1/{group}/{name}/{file}.", + "minItems": 1, + "items": { + "type": "object", + "required": ["dir"], + "additionalProperties": false, + "properties": { + "dir": {"type": "string"} + } + } + } + }, + "allOf": [ + { + "if": {"properties": {"type": {"const": "y-kustomize-local"}}}, + "then": {"required": ["sources"]} + }, + { + "if": {"properties": {"type": {"const": "static"}}}, + "then": {"required": ["static"]} + } + ] +} diff --git a/pkg/serve/serve.go b/pkg/serve/serve.go new file mode 100644 index 0000000..6c7bbb8 --- /dev/null +++ b/pkg/serve/serve.go @@ -0,0 +1,307 @@ +package serve + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Options is the public input to the serve entry points. +type Options struct { + // ConfigDirs are the `-c` arguments, each a directory containing + // y-cluster-serve.yaml. + ConfigDirs []string + + // Foreground makes Run block in the current process. When false, + // Run re-execs itself detached and returns once /health is ready. + Foreground bool + + // StateDir overrides the per-user state directory. Empty uses + // DefaultStateDir(). + StateDir string + + // ExecPath is the binary path used for background re-exec. Empty + // uses os.Executable(). Set by tests to pin a built binary. + ExecPath string + + // HealthTimeout caps how long Ensure/Run wait for ports to become + // healthy after start. Zero uses 10s. + HealthTimeout time.Duration +} + +// Run is the main entry point. If already in daemon mode (re-execed), +// it runs the server loop; otherwise it validates config and either +// runs in foreground or spawns a background child. +func Run(ctx context.Context, opts Options) error { + if err := refuseRoot(); err != nil { + return err + } + + cfgs, err := LoadConfigDirs(opts.ConfigDirs) + if err != nil { + return err + } + paths, err := ResolveStatePaths(opts.StateDir) + if err != nil { + return err + } + + if daemonMode() { + return runAsDaemon(ctx, cfgs, paths) + } + + if opts.Foreground { + return runForeground(ctx, cfgs, paths) + } + + return startBackground(ctx, cfgs, paths, opts) +} + +// Ensure launches or restarts the daemon so that the configured set +// matches opts.ConfigDirs and /health returns 200 on every port. +// Returns started=true if a new daemon was launched. +func Ensure(ctx context.Context, opts Options) (bool, error) { + if err := refuseRoot(); err != nil { + return false, err + } + + cfgs, err := LoadConfigDirs(opts.ConfigDirs) + if err != nil { + return false, err + } + paths, err := ResolveStatePaths(opts.StateDir) + if err != nil { + return false, err + } + + want := Digest(cfgs) + have, healthy := inspectRunning(paths, cfgs) + if healthy && have == want { + return false, nil + } + if have != "" { + if err := stopByPidfile(paths, 10*time.Second); err != nil { + return false, fmt.Errorf("stop stale daemon: %w", err) + } + } + if err := startBackground(ctx, cfgs, paths, opts); err != nil { + return false, err + } + return true, nil +} + +// Stop terminates a running daemon. Idempotent. +func Stop(ctx context.Context, stateDir string) error { + paths, err := ResolveStatePaths(stateDir) + if err != nil { + return err + } + return stopByPidfile(paths, 10*time.Second) +} + +// Logs prints the contents of the serve log file to w. Follow=true +// tails it by repeatedly reading EOF until ctx is done. +func Logs(ctx context.Context, w io.Writer, stateDir string, follow bool) error { + paths, err := ResolveStatePaths(stateDir) + if err != nil { + return err + } + f, err := os.Open(paths.Log) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + defer f.Close() + if !follow { + _, err = io.Copy(w, f) + return err + } + br := bufio.NewReader(f) + for { + if err := ctx.Err(); err != nil { + return nil + } + line, err := br.ReadString('\n') + if len(line) > 0 { + _, _ = w.Write([]byte(line)) + } + if err == io.EOF { + select { + case <-ctx.Done(): + return nil + case <-time.After(200 * time.Millisecond): + } + continue + } + if err != nil { + return err + } + } +} + +// --- internal helpers --- + +// inspectRunning reports the digest stored beside a live daemon, and +// whether /health on every configured port returns 200 right now. +// Returns ("", false) when no daemon is running. +func inspectRunning(paths StatePaths, cfgs []*Config) (string, bool) { + pid, err := ReadPidfile(paths.Pid) + if err != nil || pid == 0 || !PidAlive(pid) { + return "", false + } + data, err := os.ReadFile(paths.Config) + if err != nil { + return "", false + } + var snap struct { + Digest string `json:"digest"` + } + if err := json.Unmarshal(data, &snap); err != nil { + return "", false + } + urls := make([]string, 0, len(cfgs)) + for _, c := range cfgs { + urls = append(urls, fmt.Sprintf("http://127.0.0.1:%d/health", c.Port)) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + healthy := waitHealthy(ctx, urls, 2*time.Second) == nil + return snap.Digest, healthy +} + +// spawnFn is an injection point for tests; defaults to the real re-exec. +var spawnFn = spawnBackground + +func startBackground(ctx context.Context, cfgs []*Config, paths StatePaths, opts Options) error { + execPath := opts.ExecPath + if execPath == "" { + p, err := os.Executable() + if err != nil { + return fmt.Errorf("executable: %w", err) + } + execPath = p + } + args := []string{"serve", "--foreground", "--state-dir", paths.Dir} + for _, d := range opts.ConfigDirs { + args = append(args, "-c", d) + } + pid, err := spawnFn(execPath, args, paths) + if err != nil { + return err + } + healthTimeout := opts.HealthTimeout + if healthTimeout == 0 { + healthTimeout = 10 * time.Second + } + urls := make([]string, 0, len(cfgs)) + for _, c := range cfgs { + urls = append(urls, fmt.Sprintf("http://127.0.0.1:%d/health", c.Port)) + } + if err := waitHealthy(ctx, urls, healthTimeout); err != nil { + return fmt.Errorf("daemon pid %d started but not healthy: %w", pid, err) + } + return nil +} + +// runForeground runs the daemon body in-process with console logging to +// stderr. Does NOT write a pidfile — the point of foreground is to +// opt out of the single-instance contract. +func runForeground(parent context.Context, cfgs []*Config, paths StatePaths) error { + logger := newConsoleLogger() + defer func() { _ = logger.Sync() }() + ctx, cancel := withSignals(parent) + defer cancel() + servers, _, err := buildServers(cfgs, logger) + if err != nil { + return err + } + return runDaemon(ctx, servers, logger) +} + +// runAsDaemon is the child's entry point. Writes pidfile and digest +// snapshot, runs servers, removes pidfile on exit. +func runAsDaemon(parent context.Context, cfgs []*Config, paths StatePaths) (retErr error) { + logger := newJSONLogger() + defer func() { _ = logger.Sync() }() + + if err := WritePidfile(paths.Pid, os.Getpid()); err != nil { + logger.Error("write pidfile", zap.Error(err)) + return err + } + defer func() { + _ = os.Remove(paths.Pid) + }() + + snap := map[string]string{"digest": Digest(cfgs)} + data, _ := json.Marshal(snap) + if err := os.WriteFile(paths.Config, data, 0o600); err != nil { + logger.Error("write config snapshot", zap.Error(err)) + return err + } + defer func() { + _ = os.Remove(paths.Config) + }() + + defer func() { + if r := recover(); r != nil { + logger.Error("daemon panic", zap.Any("panic", r)) + retErr = fmt.Errorf("daemon panic: %v", r) + } + }() + + ctx, cancel := withSignals(parent) + defer cancel() + servers, _, err := buildServers(cfgs, logger) + if err != nil { + logger.Error("build servers", zap.Error(err)) + return err + } + return runDaemon(ctx, servers, logger) +} + +// refuseRoot honors SERVE_FEATURE.md: the server must refuse to run as +// UID 0. +func refuseRoot() error { + if os.Geteuid() == 0 { + return errors.New("y-cluster serve refuses to run as root; use an unprivileged user") + } + return nil +} + +// --- loggers --- + +// newJSONLogger is used in the background daemon per Q-S1. +func newJSONLogger() *zap.Logger { + cfg := zap.NewProductionConfig() + cfg.OutputPaths = []string{"stdout"} + cfg.ErrorOutputPaths = []string{"stderr"} + cfg.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + logger, err := cfg.Build() + if err != nil { + panic(err) + } + return logger +} + +// newConsoleLogger is used in --foreground so humans reading the tty +// see readable output. +func newConsoleLogger() *zap.Logger { + cfg := zap.NewDevelopmentConfig() + cfg.OutputPaths = []string{"stderr"} + cfg.ErrorOutputPaths = []string{"stderr"} + logger, err := cfg.Build() + if err != nil { + panic(err) + } + return logger +} diff --git a/pkg/serve/serve_test.go b/pkg/serve/serve_test.go new file mode 100644 index 0000000..08a86cb --- /dev/null +++ b/pkg/serve/serve_test.go @@ -0,0 +1,387 @@ +package serve + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// seedYKCfg writes a minimal y-kustomize-local config and a single +// source dir. Returns the absolute config dir path. +func seedYKCfg(t *testing.T, port int) string { + t.Helper() + root := t.TempDir() + cfgDir := filepath.Join(root, "config") + srcDir := filepath.Join(root, "src") + seedYKBases(t, srcDir, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/values.yaml": "bucket: builds\n", + }) + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatal(err) + } + body := fmt.Sprintf("port: %d\ntype: y-kustomize-local\nsources:\n- dir: %s\n", port, srcDir) + if err := os.WriteFile(filepath.Join(cfgDir, ConfigFilename), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + return cfgDir +} + +// portFromNet asks the kernel for a free TCP port. There is a tiny +// window between release and re-bind, but it is acceptable for tests. +func portFromNet(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := l.Addr().(*net.TCPAddr).Port + _ = l.Close() + return port +} + +// installFakeSpawn replaces spawnFn with an in-process runAsDaemon for +// the duration of the test. The "daemon" writes its pidfile with the +// test process's own pid (which is alive), runs until the returned +// cancel is called, and is cleaned up on test end. +func installFakeSpawn(t *testing.T) (cancel func()) { + t.Helper() + var mu sync.Mutex + var cancels []context.CancelFunc + var wg sync.WaitGroup + + orig := spawnFn + spawnFn = func(execPath string, args []string, paths StatePaths) (int, error) { + // Parse -c dirs from args + var dirs []string + for i := 0; i < len(args); i++ { + if args[i] == "-c" && i+1 < len(args) { + dirs = append(dirs, args[i+1]) + } + } + cfgs, err := LoadConfigDirs(dirs) + if err != nil { + return 0, err + } + + ctx, c := context.WithCancel(context.Background()) + mu.Lock() + cancels = append(cancels, c) + mu.Unlock() + + wg.Add(1) + go func() { + defer wg.Done() + _ = runAsDaemon(ctx, cfgs, paths) + }() + + // Wait for pidfile to appear + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if pid, _ := ReadPidfile(paths.Pid); pid > 0 { + return pid, nil + } + time.Sleep(10 * time.Millisecond) + } + return 0, fmt.Errorf("fake daemon never wrote pidfile") + } + t.Cleanup(func() { + mu.Lock() + for _, c := range cancels { + c() + } + mu.Unlock() + wg.Wait() + spawnFn = orig + }) + return func() { + mu.Lock() + for _, c := range cancels { + c() + } + cancels = nil + mu.Unlock() + wg.Wait() + } +} + +func TestRun_Foreground(t *testing.T) { + port := portFromNet(t) + cfgDir := seedYKCfg(t, port) + stateDir := t.TempDir() + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- Run(ctx, Options{ + ConfigDirs: []string{cfgDir}, + Foreground: true, + StateDir: stateDir, + }) + }() + + // Wait until /health answers + url := fmt.Sprintf("http://127.0.0.1:%d/health", port) + if err := waitHealthy(context.Background(), []string{url}, 5*time.Second); err != nil { + cancel() + <-done + t.Fatal(err) + } + + // Known file served + resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/v1/blobs/setup-bucket-job/values.yaml", port)) + if err != nil { + cancel() + <-done + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.Contains(string(body), "bucket") { + cancel() + <-done + t.Fatalf("body: %q", body) + } + + // openapi served + resp, err = http.Get(fmt.Sprintf("http://127.0.0.1:%d/openapi.yaml", port)) + if err != nil { + cancel() + <-done + t.Fatal(err) + } + oa, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.Contains(string(oa), "/v1/blobs/setup-bucket-job/values.yaml") { + cancel() + <-done + t.Fatalf("openapi: %s", oa) + } + + cancel() + if err := <-done; err != nil { + t.Fatalf("run: %v", err) + } +} + +func TestRun_BackgroundViaFakeSpawn(t *testing.T) { + installFakeSpawn(t) + + port := portFromNet(t) + cfgDir := seedYKCfg(t, port) + stateDir := t.TempDir() + + if err := Run(context.Background(), Options{ + ConfigDirs: []string{cfgDir}, + Foreground: false, + StateDir: stateDir, + ExecPath: "/usr/bin/true", // never actually exec'd; fake spawn ignores + }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(stateDir, "serve.pid")); err != nil { + t.Fatalf("pidfile missing: %v", err) + } + if _, err := os.Stat(filepath.Join(stateDir, "serve.config.json")); err != nil { + t.Fatalf("config snapshot missing: %v", err) + } +} + +func TestEnsure_FirstStartAndNoop(t *testing.T) { + installFakeSpawn(t) + + port := portFromNet(t) + cfgDir := seedYKCfg(t, port) + stateDir := t.TempDir() + + started, err := Ensure(context.Background(), Options{ + ConfigDirs: []string{cfgDir}, + StateDir: stateDir, + ExecPath: "/usr/bin/true", + }) + if err != nil { + t.Fatal(err) + } + if !started { + t.Fatal("first ensure should start the daemon") + } + + // Second ensure with unchanged config → no-op + started2, err := Ensure(context.Background(), Options{ + ConfigDirs: []string{cfgDir}, + StateDir: stateDir, + ExecPath: "/usr/bin/true", + }) + if err != nil { + t.Fatal(err) + } + if started2 { + t.Fatal("second ensure with same config should be no-op") + } +} + +// TestEnsure_RestartWhenStaleStatePresent covers the "daemon already +// died, stale pidfile left behind" path without requiring us to SIGTERM +// the live test process (which stopByPidfile would escalate to SIGKILL, +// killing the test itself). +func TestEnsure_RestartWhenStaleStatePresent(t *testing.T) { + installFakeSpawn(t) + + port := portFromNet(t) + cfgDir := seedYKCfg(t, port) + stateDir := t.TempDir() + + // Pretend a previous daemon ran but died. + if err := WritePidfile(filepath.Join(stateDir, "serve.pid"), 99999999); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stateDir, "serve.config.json"), + []byte(`{"digest":"stale"}`), 0o600); err != nil { + t.Fatal(err) + } + + started, err := Ensure(context.Background(), Options{ + ConfigDirs: []string{cfgDir}, + StateDir: stateDir, + ExecPath: "/usr/bin/true", + }) + if err != nil { + t.Fatal(err) + } + if !started { + t.Fatal("ensure should restart when pidfile is stale") + } +} + +func TestStop_Idempotent(t *testing.T) { + stateDir := t.TempDir() + if err := Stop(context.Background(), stateDir); err != nil { + t.Fatal(err) + } + // Stale pidfile + pidfile := filepath.Join(stateDir, "serve.pid") + if err := os.WriteFile(pidfile, []byte("99999999\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := Stop(context.Background(), stateDir); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(pidfile); !os.IsNotExist(err) { + t.Fatal("pidfile should be gone after stop on stale") + } +} + +func TestStop_CorruptPidfile(t *testing.T) { + stateDir := t.TempDir() + if err := os.WriteFile(filepath.Join(stateDir, "serve.pid"), []byte("not-a-number"), 0o600); err != nil { + t.Fatal(err) + } + if err := Stop(context.Background(), stateDir); err != nil { + t.Fatal(err) + } +} + + +func TestLogs_Empty(t *testing.T) { + var buf bytes.Buffer + if err := Logs(context.Background(), &buf, t.TempDir(), false); err != nil { + t.Fatal(err) + } + if buf.Len() != 0 { + t.Fatalf("expected empty, got %q", buf.String()) + } +} + +func TestLogs_ReadsFile(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "serve.log"), []byte("hello\nworld\n"), 0o600); err != nil { + t.Fatal(err) + } + var buf bytes.Buffer + if err := Logs(context.Background(), &buf, dir, false); err != nil { + t.Fatal(err) + } + if buf.String() != "hello\nworld\n" { + t.Fatalf("got %q", buf.String()) + } +} + +func TestLogs_Follow(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "serve.log") + if err := os.WriteFile(logPath, []byte("first\n"), 0o600); err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + var buf bytes.Buffer + done := make(chan error, 1) + go func() { + done <- Logs(ctx, &buf, dir, true) + }() + + // Append while following + time.Sleep(100 * time.Millisecond) + f, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + f.WriteString("second\n") + f.Close() + + if err := <-done; err != nil { + t.Fatal(err) + } + if !strings.Contains(buf.String(), "first") || !strings.Contains(buf.String(), "second") { + t.Fatalf("follow missed a line: %q", buf.String()) + } +} + +func TestBuildServers_StaticNotImplemented(t *testing.T) { + c := &Config{Port: 1, Type: TypeStatic, Static: &StaticConfig{Dir: "/x"}, Dir: t.TempDir()} + logger := newConsoleLogger() + _, _, err := buildServers([]*Config{c}, logger) + if err == nil || !strings.Contains(err.Error(), "not implemented") { + t.Fatalf("want not-implemented, got %v", err) + } +} + +func TestBuildServers_UnknownType(t *testing.T) { + c := &Config{Port: 1, Type: BackendType("weird"), Dir: t.TempDir()} + logger := newConsoleLogger() + _, _, err := buildServers([]*Config{c}, logger) + if err == nil || !strings.Contains(err.Error(), "unknown type") { + t.Fatalf("want unknown-type, got %v", err) + } +} + +func TestRefuseRoot_WhenNotRoot(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("running as root") + } + if err := refuseRoot(); err != nil { + t.Fatalf("refuseRoot should pass when not root: %v", err) + } +} + +func TestLoggers_Build(t *testing.T) { + j := newJSONLogger() + if j == nil { + t.Fatal("json logger nil") + } + _ = j.Sync() + c := newConsoleLogger() + if c == nil { + t.Fatal("console logger nil") + } + _ = c.Sync() +} diff --git a/pkg/serve/spawn_other.go b/pkg/serve/spawn_other.go new file mode 100644 index 0000000..82bb106 --- /dev/null +++ b/pkg/serve/spawn_other.go @@ -0,0 +1,11 @@ +//go:build !unix + +package serve + +import "fmt" + +// spawnBackground is not supported on non-unix platforms in the first +// release. Release artifacts target linux and darwin. +func spawnBackground(execPath string, args []string, paths StatePaths) (int, error) { + return 0, fmt.Errorf("background daemon not supported on this platform; pass --foreground") +} diff --git a/pkg/serve/spawn_unix.go b/pkg/serve/spawn_unix.go new file mode 100644 index 0000000..cde4014 --- /dev/null +++ b/pkg/serve/spawn_unix.go @@ -0,0 +1,48 @@ +//go:build unix + +package serve + +import ( + "fmt" + "os" + "os/exec" + "syscall" + "time" +) + +// spawnBackground re-execs the current binary in daemon mode, detaches +// it (Setsid), redirects stdout/stderr to paths.Log, closes stdin, and +// returns the child pid. The caller is responsible for not waiting on +// the child — we detach via cmd.Process.Release(). +func spawnBackground(execPath string, args []string, paths StatePaths) (int, error) { + logf, err := os.OpenFile(paths.Log, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + return 0, fmt.Errorf("open log: %w", err) + } + defer logf.Close() + + devnull, err := os.OpenFile(os.DevNull, os.O_RDONLY, 0) + if err != nil { + return 0, fmt.Errorf("open /dev/null: %w", err) + } + defer devnull.Close() + + cmd := exec.Command(execPath, args...) + cmd.Env = append(os.Environ(), daemonEnv+"=1") + cmd.Stdin = devnull + cmd.Stdout = logf + cmd.Stderr = logf + cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + + if err := cmd.Start(); err != nil { + return 0, fmt.Errorf("spawn: %w", err) + } + pid := cmd.Process.Pid + if err := cmd.Process.Release(); err != nil { + return pid, fmt.Errorf("release: %w", err) + } + // Brief pause so the child has a chance to write its pidfile; the + // caller then polls /health, so this is just to avoid a tight loop. + time.Sleep(50 * time.Millisecond) + return pid, nil +} diff --git a/pkg/serve/state.go b/pkg/serve/state.go new file mode 100644 index 0000000..e707bbf --- /dev/null +++ b/pkg/serve/state.go @@ -0,0 +1,112 @@ +package serve + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" +) + +// stateDirEnv lets tests (and advanced users) pin the state dir without +// depending on OS-specific env vars. +const stateDirEnv = "Y_CLUSTER_SERVE_STATE_DIR" + +// StatePaths groups the files the daemon and CLI share via the state dir. +type StatePaths struct { + Dir string + Pid string // serve.pid + Log string // serve.log + Config string // serve.config.json (normalized digest/manifest) +} + +// DefaultStateDir resolves the per-user state directory using OS +// conventions. Callers that already know the directory should pass it +// explicitly to ResolveStatePaths instead. +func DefaultStateDir() (string, error) { + if v := os.Getenv(stateDirEnv); v != "" { + return filepath.Clean(v), nil + } + switch runtime.GOOS { + case "darwin": + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "Library", "Application Support", "y-cluster", "serve"), nil + case "windows": + if v := os.Getenv("LocalAppData"); v != "" { + return filepath.Join(v, "y-cluster", "serve"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, "AppData", "Local", "y-cluster", "serve"), nil + default: + if v := os.Getenv("XDG_STATE_HOME"); v != "" { + return filepath.Join(v, "y-cluster", "serve"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".local", "state", "y-cluster", "serve"), nil + } +} + +// ResolveStatePaths returns the full set of paths, creating the dir if +// it does not exist. +func ResolveStatePaths(dir string) (StatePaths, error) { + if dir == "" { + d, err := DefaultStateDir() + if err != nil { + return StatePaths{}, err + } + dir = d + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return StatePaths{}, fmt.Errorf("create state dir %s: %w", dir, err) + } + return StatePaths{ + Dir: dir, + Pid: filepath.Join(dir, "serve.pid"), + Log: filepath.Join(dir, "serve.log"), + Config: filepath.Join(dir, "serve.config.json"), + }, nil +} + +// WritePidfile writes pid to the pidfile with 0600 perms. +func WritePidfile(path string, pid int) error { + return os.WriteFile(path, []byte(strconv.Itoa(pid)+"\n"), 0o600) +} + +// ReadPidfile returns the pid; (0, nil) if the pidfile does not exist. +func ReadPidfile(path string) (int, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, err + } + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err != nil { + return 0, fmt.Errorf("pidfile %s: %w", path, err) + } + return pid, nil +} + +// PidAlive reports whether pid is alive. Returns false for pid==0. +func PidAlive(pid int) bool { + if pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} diff --git a/pkg/serve/state_test.go b/pkg/serve/state_test.go new file mode 100644 index 0000000..7ba025d --- /dev/null +++ b/pkg/serve/state_test.go @@ -0,0 +1,112 @@ +package serve + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestDefaultStateDir_EnvOverride(t *testing.T) { + t.Setenv(stateDirEnv, "/tmp/override") + got, err := DefaultStateDir() + if err != nil { + t.Fatal(err) + } + if got != "/tmp/override" { + t.Fatalf("env override ignored: %s", got) + } +} + +func TestDefaultStateDir_PerOS(t *testing.T) { + t.Setenv(stateDirEnv, "") + switch runtime.GOOS { + case "linux": + t.Setenv("XDG_STATE_HOME", "/tmp/xdg") + got, err := DefaultStateDir() + if err != nil { + t.Fatal(err) + } + if got != "/tmp/xdg/y-cluster/serve" { + t.Fatalf("xdg: %s", got) + } + t.Setenv("XDG_STATE_HOME", "") + got, err = DefaultStateDir() + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(got, ".local/state/y-cluster/serve") { + t.Fatalf("fallback: %s", got) + } + case "darwin": + got, err := DefaultStateDir() + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "Library/Application Support/y-cluster/serve") { + t.Fatalf("darwin: %s", got) + } + } +} + +func TestResolveStatePaths_CreatesDir(t *testing.T) { + dir := filepath.Join(t.TempDir(), "a", "b") + sp, err := ResolveStatePaths(dir) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dir); err != nil { + t.Fatal(err) + } + if sp.Pid == "" || sp.Log == "" || sp.Config == "" { + t.Fatal("paths empty") + } +} + +func TestResolveStatePaths_EmptyUsesDefault(t *testing.T) { + t.Setenv(stateDirEnv, filepath.Join(t.TempDir(), "envdir")) + sp, err := ResolveStatePaths("") + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(sp.Dir, "envdir") { + t.Fatalf("dir: %s", sp.Dir) + } +} + +func TestPidfileRoundtrip(t *testing.T) { + p := filepath.Join(t.TempDir(), "pid") + if pid, err := ReadPidfile(p); err != nil || pid != 0 { + t.Fatalf("missing pidfile should be (0, nil), got (%d, %v)", pid, err) + } + if err := WritePidfile(p, 1234); err != nil { + t.Fatal(err) + } + pid, err := ReadPidfile(p) + if err != nil || pid != 1234 { + t.Fatalf("round-trip: %d %v", pid, err) + } +} + +func TestReadPidfile_Corrupt(t *testing.T) { + p := filepath.Join(t.TempDir(), "pid") + if err := os.WriteFile(p, []byte("not-a-number"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := ReadPidfile(p); err == nil { + t.Fatal("want parse error") + } +} + +func TestPidAlive_SelfAndZero(t *testing.T) { + if !PidAlive(os.Getpid()) { + t.Fatal("self should be alive") + } + if PidAlive(0) || PidAlive(-1) { + t.Fatal("0/-1 should be not alive") + } + if PidAlive(0x7fffffff) { + t.Fatal("unlikely pid should be not alive") + } +} diff --git a/pkg/serve/static.go b/pkg/serve/static.go new file mode 100644 index 0000000..b56e4b9 --- /dev/null +++ b/pkg/serve/static.go @@ -0,0 +1,7 @@ +package serve + +// Static backend is declared in the config schema so configs written +// against the final shape round-trip, but the runtime is not +// implemented in the first release. See SERVE_FEATURE.md §"Initial +// scope" — y-kustomize-local is the only backend wired up. The schema +// stub lives here so future work replaces this single file. diff --git a/pkg/serve/ykustomizelocal.go b/pkg/serve/ykustomizelocal.go new file mode 100644 index 0000000..5a0d727 --- /dev/null +++ b/pkg/serve/ykustomizelocal.go @@ -0,0 +1,159 @@ +package serve + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" +) + +// yKustomizeBasesDir is the conventional subdirectory in a source. +const yKustomizeBasesDir = "y-kustomize-bases" + +// ykRoute is a resolved path → file mapping with metadata used to emit +// the openapi spec. +type ykRoute struct { + Path string // e.g. /v1/blobs/setup-bucket-job/base-for-annotations.yaml + FilePath string // absolute filesystem path + ContentType string // detected at scan time, used by openapi +} + +// ykBackend serves a frozen map of /v1 routes from scanned sources. +type ykBackend struct { + cfg *Config + routes map[string]ykRoute + order []string // sorted paths, for openapi stability +} + +// newYKustomizeLocalBackend scans every source dir and builds a route +// table. Duplicate routes across sources are a fatal error with both +// source paths in the message. +func newYKustomizeLocalBackend(cfg *Config) (*ykBackend, error) { + if cfg.Type != TypeYKustomizeLocal { + return nil, fmt.Errorf("not a y-kustomize-local config: %s", cfg.Type) + } + sources := cfg.ResolvedSources() + if len(sources) == 0 { + return nil, fmt.Errorf("no sources") + } + + routes := map[string]ykRoute{} + origin := map[string]string{} // route → source dir (for dup error) + + for _, src := range sources { + info, err := os.Stat(src) + if err != nil { + return nil, fmt.Errorf("source %s: %w", src, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("source %s is not a directory", src) + } + basesDir := filepath.Join(src, yKustomizeBasesDir) + basesInfo, err := os.Stat(basesDir) + if err != nil { + return nil, fmt.Errorf("source %s: missing %s/", src, yKustomizeBasesDir) + } + if !basesInfo.IsDir() { + return nil, fmt.Errorf("source %s: %s is not a directory", src, yKustomizeBasesDir) + } + + scanned, err := scanYKustomizeBases(basesDir) + if err != nil { + return nil, fmt.Errorf("scan %s: %w", basesDir, err) + } + for _, r := range scanned { + if prev, dup := origin[r.Path]; dup { + return nil, fmt.Errorf("duplicate route %s from %s and %s", r.Path, prev, src) + } + routes[r.Path] = r + origin[r.Path] = src + } + } + + order := make([]string, 0, len(routes)) + for p := range routes { + order = append(order, p) + } + sort.Strings(order) + + return &ykBackend{cfg: cfg, routes: routes, order: order}, nil +} + +// scanYKustomizeBases walks {basesDir}/{group}/{name}/{file} and returns +// the resulting routes. Files outside the {group}/{name}/ layer, or +// non-file leaves, are ignored. +func scanYKustomizeBases(basesDir string) ([]ykRoute, error) { + groups, err := os.ReadDir(basesDir) + if err != nil { + return nil, err + } + var out []ykRoute + for _, g := range groups { + if !g.IsDir() { + continue + } + groupPath := filepath.Join(basesDir, g.Name()) + names, err := os.ReadDir(groupPath) + if err != nil { + return nil, err + } + for _, n := range names { + if !n.IsDir() { + continue + } + namePath := filepath.Join(groupPath, n.Name()) + files, err := os.ReadDir(namePath) + if err != nil { + return nil, err + } + for _, f := range files { + if f.IsDir() { + continue + } + filePath := filepath.Join(namePath, f.Name()) + route := fmt.Sprintf("/v1/%s/%s/%s", g.Name(), n.Name(), f.Name()) + out = append(out, ykRoute{ + Path: route, + FilePath: filePath, + ContentType: DetectContentType(f.Name()), + }) + } + } + } + return out, nil +} + +// ServeHTTP implements http.Handler. Only /v1/** paths are served; other +// paths fall through to 404 so the parent mux can route /health and +// /openapi.yaml. +func (b *ykBackend) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + MethodNotAllowed(w, http.MethodGet, http.MethodHead) + return + } + if !strings.HasPrefix(r.URL.Path, "/v1/") { + http.NotFound(w, r) + return + } + route, ok := b.routes[r.URL.Path] + if !ok { + http.NotFound(w, r) + return + } + body, err := os.ReadFile(route.FilePath) + if err != nil { + http.Error(w, "read: "+err.Error(), http.StatusInternalServerError) + return + } + WriteAsset(w, r, route.FilePath, body) +} + +// Routes returns the sorted list of served paths (stable order). +func (b *ykBackend) Routes() []string { return b.order } + +// RouteContentType returns the content type a route will be served with. +func (b *ykBackend) RouteContentType(path string) string { + return b.routes[path].ContentType +} diff --git a/pkg/serve/ykustomizelocal_test.go b/pkg/serve/ykustomizelocal_test.go new file mode 100644 index 0000000..80c1a84 --- /dev/null +++ b/pkg/serve/ykustomizelocal_test.go @@ -0,0 +1,233 @@ +package serve + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func seedYKBases(t *testing.T, root string, files map[string]string) { + t.Helper() + for rel, body := range files { + abs := filepath.Join(root, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } +} + +func cfgWithSources(t *testing.T, sources ...string) *Config { + t.Helper() + out := &Config{ + Dir: t.TempDir(), + Port: 1, + Type: TypeYKustomizeLocal, + } + for _, s := range sources { + out.Sources = append(out.Sources, YKustomizeLocalSource{Dir: s}) + } + return out +} + +func TestYK_SingleSource(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml": "kind: Job\n", + "y-kustomize-bases/blobs/setup-bucket-job/values.yaml": "bucket: builds\n", + }) + b, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err != nil { + t.Fatal(err) + } + got := b.Routes() + want := []string{ + "/v1/blobs/setup-bucket-job/base-for-annotations.yaml", + "/v1/blobs/setup-bucket-job/values.yaml", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("got %v want %v", got, want) + } +} + +func TestYK_TwoSourcesMerge(t *testing.T) { + a := t.TempDir() + b := t.TempDir() + seedYKBases(t, a, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml": "A\n", + }) + seedYKBases(t, b, map[string]string{ + "y-kustomize-bases/kafka/setup-topic-job/base-for-annotations.yaml": "B\n", + }) + back, err := newYKustomizeLocalBackend(cfgWithSources(t, a, b)) + if err != nil { + t.Fatal(err) + } + if len(back.Routes()) != 2 { + t.Fatalf("routes: %v", back.Routes()) + } +} + +func TestYK_DuplicateAcrossSources(t *testing.T) { + a := t.TempDir() + b := t.TempDir() + for _, d := range []string{a, b} { + seedYKBases(t, d, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/x.yaml": "k\n", + }) + } + _, err := newYKustomizeLocalBackend(cfgWithSources(t, a, b)) + if err == nil || !strings.Contains(err.Error(), "duplicate route") { + t.Fatalf("want duplicate error, got %v", err) + } + if !strings.Contains(err.Error(), a) || !strings.Contains(err.Error(), b) { + t.Fatalf("dup error should mention both sources: %v", err) + } +} + +func TestYK_MissingBasesDir(t *testing.T) { + src := t.TempDir() // no y-kustomize-bases/ + _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("want missing error, got %v", err) + } +} + +func TestYK_SourceIsFile(t *testing.T) { + f := filepath.Join(t.TempDir(), "file") + os.WriteFile(f, []byte("x"), 0o644) + _, err := newYKustomizeLocalBackend(cfgWithSources(t, f)) + if err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("want not-a-directory error, got %v", err) + } +} + +func TestYK_BasesIsFile(t *testing.T) { + src := t.TempDir() + os.WriteFile(filepath.Join(src, "y-kustomize-bases"), []byte("x"), 0o644) + _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err == nil || !strings.Contains(err.Error(), "not a directory") { + t.Fatalf("want not-a-directory error, got %v", err) + } +} + +func TestYK_NonFileLeavesIgnored(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/values.yaml": "k\n", + "y-kustomize-bases/blobs/setup-bucket-job/subdir/ignored.yaml": "k\n", + }) + b, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err != nil { + t.Fatal(err) + } + for _, p := range b.Routes() { + if strings.Contains(p, "/subdir/") { + t.Fatalf("subdir leaked into route: %s", p) + } + } +} + +func TestYK_ServeHTTP_200And304(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/values.yaml": "bucket: builds\n", + }) + b, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err != nil { + t.Fatal(err) + } + s := httptest.NewServer(b) + defer s.Close() + + resp, err := http.Get(s.URL + "/v1/blobs/setup-bucket-job/values.yaml") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != "bucket: builds\n" { + t.Fatalf("body %q", body) + } + if resp.Header.Get("Content-Type") != yamlMIME { + t.Fatalf("content-type: %s", resp.Header.Get("Content-Type")) + } + etag := resp.Header.Get("ETag") + + req, _ := http.NewRequest(http.MethodGet, s.URL+"/v1/blobs/setup-bucket-job/values.yaml", nil) + req.Header.Set("If-None-Match", etag) + resp2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp2.Body.Close() + if resp2.StatusCode != http.StatusNotModified { + t.Fatalf("304 expected, got %d", resp2.StatusCode) + } +} + +func TestYK_ServeHTTP_404AndMethod(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/values.yaml": "k\n", + }) + b, _ := newYKustomizeLocalBackend(cfgWithSources(t, src)) + s := httptest.NewServer(b) + defer s.Close() + + // Unknown path + resp, _ := http.Get(s.URL + "/v1/nope") + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("want 404, got %d", resp.StatusCode) + } + // Outside /v1/ + resp, _ = http.Get(s.URL + "/somethingelse") + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("outside /v1/: want 404, got %d", resp.StatusCode) + } + // POST + resp, _ = http.Post(s.URL+"/v1/blobs/setup-bucket-job/values.yaml", "text/plain", strings.NewReader("x")) + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST: %d", resp.StatusCode) + } +} + +func TestYK_ReadFileError(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/values.yaml": "k\n", + }) + b, _ := newYKustomizeLocalBackend(cfgWithSources(t, src)) + // Remove the file after scan + os.Remove(filepath.Join(src, "y-kustomize-bases/blobs/setup-bucket-job/values.yaml")) + s := httptest.NewServer(b) + defer s.Close() + resp, _ := http.Get(s.URL + "/v1/blobs/setup-bucket-job/values.yaml") + resp.Body.Close() + if resp.StatusCode != 500 { + t.Fatalf("want 500, got %d", resp.StatusCode) + } +} + +func TestYK_WrongType(t *testing.T) { + c := &Config{Port: 1, Type: TypeStatic, Dir: t.TempDir()} + if _, err := newYKustomizeLocalBackend(c); err == nil { + t.Fatal("want error") + } +} + +func TestYK_NoSources(t *testing.T) { + c := &Config{Port: 1, Type: TypeYKustomizeLocal, Dir: t.TempDir()} + if _, err := newYKustomizeLocalBackend(c); err == nil { + t.Fatal("want error") + } +} From 31c193aece09fb86d20dd9fcc8a46ad1ec3fc708 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 11:17:53 +0000 Subject: [PATCH 29/43] ci: lint + e2e-serve job; release-asset validation workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phases 6 and 7 of SERVE_PLAN.md. ci.yaml: add golangci-lint (errcheck, govet, staticcheck, unused) and an e2e-serve job that builds cmd/y-cluster and runs the serve e2e against the fresh build. Partial payback on Q16 (CI.md drift). .goreleaser.yaml: add a y-cluster build entry alongside the existing kustomize-traverse one; rename the project to y-cluster; attach the y-cluster-serve JSON schema as a release artifact under schema/. e2e-release.yaml (new) + scripts/e2e-serve-against-binary.sh (new): on release publication (and manually), download the tagged release archive on ubuntu-latest and macos-latest, then run a bash-level lifecycle -- ensure → GET /health, /v1/*, /openapi.yaml with ETag + 304 assertion → stop → stop again -- against the shipped binary. This is the 'first use case validated based on a GitHub release' gate the maintainer asked for. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 24 +++++++ .github/workflows/e2e-release.yaml | 59 +++++++++++++++++ .golangci.yaml | 10 +++ .goreleaser.yaml | 35 +++++++++-- scripts/e2e-serve-against-binary.sh | 98 +++++++++++++++++++++++++++++ 5 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/e2e-release.yaml create mode 100644 .golangci.yaml create mode 100755 scripts/e2e-serve-against-binary.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2c4b364..cc46f44 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -17,3 +17,27 @@ jobs: go-version-file: go.mod cache: true - run: go test ./... + - run: go vet ./... + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: v2.5.0 + args: --timeout=5m + + e2e-serve: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - run: go test -tags e2e -count=1 -timeout=5m -run TestServe_ ./e2e/ diff --git a/.github/workflows/e2e-release.yaml b/.github/workflows/e2e-release.yaml new file mode 100644 index 0000000..f410f3f --- /dev/null +++ b/.github/workflows/e2e-release.yaml @@ -0,0 +1,59 @@ +name: e2e Release + +# Validates that an actually-published y-cluster release archive works +# end-to-end on the platforms we support. Runs on tag publication and +# can be re-run manually against any published tag. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to test" + required: true + +jobs: + serve: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + asset: y-cluster-linux-amd64.tar.gz + - os: macos-latest + asset: y-cluster-darwin-arm64.tar.gz + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Resolve tag + id: tag + shell: bash + run: | + if [ -n "${{ github.event.release.tag_name }}" ]; then + echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + else + echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + fi + + - name: Download release asset + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tag='${{ steps.tag.outputs.tag }}' + gh release download "$tag" \ + --repo Yolean/y-cluster \ + --pattern '${{ matrix.asset }}' \ + --dir . + tar -xzf '${{ matrix.asset }}' + test -x y-cluster + ./y-cluster --version + + - name: Run serve e2e against the release binary + shell: bash + env: + Y_CLUSTER_BIN: ./y-cluster + run: ./scripts/e2e-serve-against-binary.sh diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..036c8c0 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,10 @@ +version: "2" +run: + timeout: 5m +linters: + default: none + enable: + - errcheck + - govet + - staticcheck + - unused diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 1ad5cac..14abaa6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,6 +1,6 @@ version: 2 -project_name: kustomize-traverse +project_name: y-cluster before: hooks: @@ -21,14 +21,41 @@ builds: ldflags: - -s -w + - id: y-cluster + main: ./cmd/y-cluster + binary: y-cluster + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + ldflags: + - -s -w -X main.version={{.Version}} + archives: - - id: default - name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" + - id: kustomize-traverse + ids: [kustomize-traverse] + name_template: "kustomize-traverse-{{ .Os }}-{{ .Arch }}" + formats: [tar.gz] + files: + - LICENSE* + - README* + - SPEC* + + - id: y-cluster + ids: [y-cluster] + name_template: "y-cluster-{{ .Os }}-{{ .Arch }}" formats: [tar.gz] files: - LICENSE* - README* - SPEC* + - SERVE_FEATURE* + - src: pkg/serve/schema/y-cluster-serve.schema.json + dst: schema checksum: name_template: "checksums-sha256.txt" @@ -49,4 +76,4 @@ changelog: release: github: owner: Yolean - name: kustomize-traverse + name: y-cluster diff --git a/scripts/e2e-serve-against-binary.sh b/scripts/e2e-serve-against-binary.sh new file mode 100755 index 0000000..aa60044 --- /dev/null +++ b/scripts/e2e-serve-against-binary.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# End-to-end test for a released y-cluster binary. +# +# Expects $Y_CLUSTER_BIN to point at a y-cluster executable. Creates a +# temp workspace with a two-base y-kustomize-local fixture, runs +# serve ensure → GET → serve stop +# and exits non-zero on any assertion failure. +# +# Intended to run in .github/workflows/e2e-release.yaml on ubuntu-latest +# and macos-latest against the extracted release archive. +set -euo pipefail + +Y_CLUSTER_BIN="${Y_CLUSTER_BIN:-./y-cluster}" +if [ ! -x "$Y_CLUSTER_BIN" ]; then + echo "Y_CLUSTER_BIN is not executable: $Y_CLUSTER_BIN" >&2 + exit 2 +fi + +work=$(mktemp -d 2>/dev/null || mktemp -d -t 'y-cluster-e2e') +trap '"$Y_CLUSTER_BIN" serve stop --state-dir "$work/state" >/dev/null 2>&1 || true; rm -rf "$work"' EXIT + +cfg="$work/config" +src_a="$work/sources/a" +src_b="$work/sources/b" +state="$work/state" +mkdir -p "$cfg" "$src_a/y-kustomize-bases/blobs/setup-bucket-job" \ + "$src_b/y-kustomize-bases/kafka/setup-topic-job" "$state" + +cat >"$src_a/y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml" <<'EOF' +apiVersion: batch/v1 +kind: Job +metadata: + name: setup-bucket-job +EOF + +cat >"$src_a/y-kustomize-bases/blobs/setup-bucket-job/values.yaml" <<'EOF' +bucket: builds +EOF + +cat >"$src_b/y-kustomize-bases/kafka/setup-topic-job/base-for-annotations.yaml" <<'EOF' +apiVersion: batch/v1 +kind: Job +metadata: + name: setup-topic-job +EOF + +# Pick an ephemeral port: ask Python (present on both runners). +port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1",0)); print(s.getsockname()[1]); s.close()') +cat >"$cfg/y-cluster-serve.yaml" < starting y-cluster serve on :$port" +"$Y_CLUSTER_BIN" serve ensure -c "$cfg" --state-dir "$state" + +echo "--> GET /health" +curl -fsS "http://127.0.0.1:$port/health" | grep -q '"ok":true' + +echo "--> GET a file from source A" +body=$(curl -fsS "http://127.0.0.1:$port/v1/blobs/setup-bucket-job/values.yaml") +echo "$body" | grep -q "bucket: builds" + +echo "--> GET a file from source B" +curl -fsS "http://127.0.0.1:$port/v1/kafka/setup-topic-job/base-for-annotations.yaml" \ + | grep -q "setup-topic-job" + +echo "--> GET /openapi.yaml" +spec=$(curl -fsS "http://127.0.0.1:$port/openapi.yaml") +echo "$spec" | grep -q "/v1/blobs/setup-bucket-job/values.yaml" +echo "$spec" | grep -q "/v1/kafka/setup-topic-job/base-for-annotations.yaml" + +echo "--> ETag + 304" +etag=$(curl -fsS -o /dev/null -D - "http://127.0.0.1:$port/v1/blobs/setup-bucket-job/values.yaml" \ + | awk -F': ' 'tolower($1)=="etag"{gsub(/\r/,"",$2); print $2}') +if [ -z "$etag" ]; then + echo "missing ETag" >&2; exit 1 +fi +code=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "If-None-Match: $etag" \ + "http://127.0.0.1:$port/v1/blobs/setup-bucket-job/values.yaml") +if [ "$code" != "304" ]; then + echo "conditional GET expected 304, got $code" >&2; exit 1 +fi + +echo "--> ensure again is a no-op" +"$Y_CLUSTER_BIN" serve ensure -c "$cfg" --state-dir "$state" 2>&1 | grep -q "already running" + +echo "--> stop" +"$Y_CLUSTER_BIN" serve stop --state-dir "$state" + +echo "--> stop is idempotent" +"$Y_CLUSTER_BIN" serve stop --state-dir "$state" + +echo "OK" From ab7b7735e72777aad301c02b4cf204513883aa81 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 12:17:58 +0000 Subject: [PATCH 30/43] Bail on secretGenerator file renames in y-kustomize-local sources Local serve maps routes by on-disk filename. If a kustomization.yaml uses rename syntax (key=path), the served path would silently differ from the in-cluster path. Detect this at startup and fail with guidance to rename the source file. Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/serve/ykustomizelocal.go | 41 ++++++++++++++++++++ pkg/serve/ykustomizelocal_test.go | 63 +++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/pkg/serve/ykustomizelocal.go b/pkg/serve/ykustomizelocal.go index 5a0d727..0e58008 100644 --- a/pkg/serve/ykustomizelocal.go +++ b/pkg/serve/ykustomizelocal.go @@ -7,6 +7,8 @@ import ( "path/filepath" "sort" "strings" + + "sigs.k8s.io/yaml" ) // yKustomizeBasesDir is the conventional subdirectory in a source. @@ -59,6 +61,10 @@ func newYKustomizeLocalBackend(cfg *Config) (*ykBackend, error) { return nil, fmt.Errorf("source %s: %s is not a directory", src, yKustomizeBasesDir) } + if err := checkForFileRenames(src); err != nil { + return nil, err + } + scanned, err := scanYKustomizeBases(basesDir) if err != nil { return nil, fmt.Errorf("scan %s: %w", basesDir, err) @@ -81,6 +87,41 @@ func newYKustomizeLocalBackend(cfg *Config) (*ykBackend, error) { return &ykBackend{cfg: cfg, routes: routes, order: order}, nil } +// kustomizationFile is the subset of kustomization.yaml we need for rename detection. +type kustomizationFile struct { + SecretGenerator []struct { + Files []string `yaml:"files"` + } `yaml:"secretGenerator"` +} + +// checkForFileRenames reads the kustomization.yaml in a source dir and +// fails if any secretGenerator files entry uses the key=path rename +// syntax. The local serve maps routes by on-disk filename; renames would +// cause the served path to silently differ from the in-cluster path. +func checkForFileRenames(sourceDir string) error { + kpath := filepath.Join(sourceDir, "kustomization.yaml") + data, err := os.ReadFile(kpath) + if err != nil { + // No kustomization.yaml is fine — the source just has raw bases. + return nil + } + var k kustomizationFile + if err := yaml.Unmarshal(data, &k); err != nil { + return fmt.Errorf("%s: %w", kpath, err) + } + for _, sg := range k.SecretGenerator { + for _, f := range sg.Files { + if strings.Contains(f, "=") { + return fmt.Errorf( + "%s: secretGenerator files entry %q uses rename syntax (key=path); "+ + "rename the source file to match the key so local serve and in-cluster serve produce the same routes", + kpath, f) + } + } + } + return nil +} + // scanYKustomizeBases walks {basesDir}/{group}/{name}/{file} and returns // the resulting routes. Files outside the {group}/{name}/ layer, or // non-file leaves, are ignored. diff --git a/pkg/serve/ykustomizelocal_test.go b/pkg/serve/ykustomizelocal_test.go index 80c1a84..7fc5abf 100644 --- a/pkg/serve/ykustomizelocal_test.go +++ b/pkg/serve/ykustomizelocal_test.go @@ -231,3 +231,66 @@ func TestYK_NoSources(t *testing.T) { t.Fatal("want error") } } + +func TestYK_RenameSyntaxRejected(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/kafka/setup-topic-job/setup-topic-job.yaml": "kind: Job\n", + }) + // Write a kustomization.yaml with rename syntax + kust := `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +secretGenerator: +- name: y-kustomize.kafka.setup-topic-job + files: + - base-for-annotations.yaml=y-kustomize-bases/kafka/setup-topic-job/setup-topic-job.yaml +` + os.WriteFile(filepath.Join(src, "kustomization.yaml"), []byte(kust), 0o644) + + _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err == nil { + t.Fatal("want error for rename syntax") + } + if !strings.Contains(err.Error(), "rename syntax") { + t.Fatalf("error should mention rename syntax: %v", err) + } +} + +func TestYK_NoRenameAllowed(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml": "kind: Job\n", + }) + // Write a kustomization.yaml without rename syntax + kust := `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +secretGenerator: +- name: y-kustomize.blobs.setup-bucket-job + files: + - y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml +` + os.WriteFile(filepath.Join(src, "kustomization.yaml"), []byte(kust), 0o644) + + b, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(b.Routes()) != 1 { + t.Fatalf("routes: %v", b.Routes()) + } +} + +func TestYK_NoKustomizationIsOK(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml": "kind: Job\n", + }) + // No kustomization.yaml — should still work + b, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(b.Routes()) != 1 { + t.Fatalf("routes: %v", b.Routes()) + } +} From 7ce5afcf4ac2d85bd992dccef934ec88ebed37fb Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 12:21:18 +0000 Subject: [PATCH 31/43] feat(serve): rename check via kustomize types; cover configMapGenerator Follow-up to the tester's no-rename guardrail. - Replace the hand-rolled kustomizationFile struct with traverse.LoadKustomization, which returns types.Kustomization from sigs.k8s.io/kustomize/api/types. This gives us both generator kinds for free (SecretArgs and ConfigMapArgs both embed GeneratorArgs and its FileSources) and supports kustomization.yml / Kustomization filename fallbacks. - Extend the check to configMapGenerator[].files; the same route-skew trap applies. - Error message now identifies which generator the offending entry belongs to. - New tests: ConfigMapGenerator rejection, malformed kustomization parse error names the file, alternate filenames (kustomization.yml, Kustomization) still trigger the check. - SERVE_PLAN.md documents the contract under the y-kustomize-local backend, credited to the ystack maintainer. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/serve/ykustomizelocal.go | 51 ++++++++++++------------ pkg/serve/ykustomizelocal_test.go | 65 +++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 24 deletions(-) diff --git a/pkg/serve/ykustomizelocal.go b/pkg/serve/ykustomizelocal.go index 0e58008..4fc9479 100644 --- a/pkg/serve/ykustomizelocal.go +++ b/pkg/serve/ykustomizelocal.go @@ -8,7 +8,7 @@ import ( "sort" "strings" - "sigs.k8s.io/yaml" + "github.com/Yolean/y-cluster/pkg/kustomize/traverse" ) // yKustomizeBasesDir is the conventional subdirectory in a source. @@ -87,41 +87,44 @@ func newYKustomizeLocalBackend(cfg *Config) (*ykBackend, error) { return &ykBackend{cfg: cfg, routes: routes, order: order}, nil } -// kustomizationFile is the subset of kustomization.yaml we need for rename detection. -type kustomizationFile struct { - SecretGenerator []struct { - Files []string `yaml:"files"` - } `yaml:"secretGenerator"` -} - -// checkForFileRenames reads the kustomization.yaml in a source dir and -// fails if any secretGenerator files entry uses the key=path rename -// syntax. The local serve maps routes by on-disk filename; renames would -// cause the served path to silently differ from the in-cluster path. +// checkForFileRenames reads the kustomization file (yaml/yml/Kustomization) +// in a source dir and fails if any secretGenerator or configMapGenerator +// files entry uses the [key=]path rename syntax. The local serve maps +// routes by on-disk filename; renames would cause the served path to +// silently differ from the in-cluster path. func checkForFileRenames(sourceDir string) error { - kpath := filepath.Join(sourceDir, "kustomization.yaml") - data, err := os.ReadFile(kpath) + k, kpath, err := traverse.LoadKustomization(sourceDir) if err != nil { - // No kustomization.yaml is fine — the source just has raw bases. - return nil - } - var k kustomizationFile - if err := yaml.Unmarshal(data, &k); err != nil { return fmt.Errorf("%s: %w", kpath, err) } + if k == nil { + // No kustomization file is fine — the source just has raw bases. + return nil + } for _, sg := range k.SecretGenerator { - for _, f := range sg.Files { + for _, f := range sg.FileSources { if strings.Contains(f, "=") { - return fmt.Errorf( - "%s: secretGenerator files entry %q uses rename syntax (key=path); "+ - "rename the source file to match the key so local serve and in-cluster serve produce the same routes", - kpath, f) + return renameSyntaxError(kpath, "secretGenerator", f) + } + } + } + for _, cg := range k.ConfigMapGenerator { + for _, f := range cg.FileSources { + if strings.Contains(f, "=") { + return renameSyntaxError(kpath, "configMapGenerator", f) } } } return nil } +func renameSyntaxError(kpath, generator, entry string) error { + return fmt.Errorf( + "%s: %s files entry %q uses rename syntax (key=path); "+ + "rename the source file to match the key so local serve and in-cluster serve produce the same routes", + kpath, generator, entry) +} + // scanYKustomizeBases walks {basesDir}/{group}/{name}/{file} and returns // the resulting routes. Files outside the {group}/{name}/ layer, or // non-file leaves, are ignored. diff --git a/pkg/serve/ykustomizelocal_test.go b/pkg/serve/ykustomizelocal_test.go index 7fc5abf..add253a 100644 --- a/pkg/serve/ykustomizelocal_test.go +++ b/pkg/serve/ykustomizelocal_test.go @@ -294,3 +294,68 @@ func TestYK_NoKustomizationIsOK(t *testing.T) { t.Fatalf("routes: %v", b.Routes()) } } + +func TestYK_RenameSyntaxRejected_ConfigMapGenerator(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/kafka/setup-topic-job/setup-topic-job.yaml": "kind: Job\n", + }) + kust := `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +configMapGenerator: +- name: y-kustomize.kafka.setup-topic-job + files: + - base-for-annotations.yaml=y-kustomize-bases/kafka/setup-topic-job/setup-topic-job.yaml +` + os.WriteFile(filepath.Join(src, "kustomization.yaml"), []byte(kust), 0o644) + + _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err == nil || !strings.Contains(err.Error(), "rename syntax") { + t.Fatalf("want rename-syntax error, got %v", err) + } + if !strings.Contains(err.Error(), "configMapGenerator") { + t.Fatalf("error should identify configMapGenerator: %v", err) + } +} + +func TestYK_MalformedKustomizationRejected(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/blobs/setup-bucket-job/values.yaml": "k\n", + }) + // Unclosed list → yaml parse error + os.WriteFile(filepath.Join(src, "kustomization.yaml"), + []byte("secretGenerator:\n- name: x\n files: [unclosed\n"), 0o644) + + _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err == nil { + t.Fatal("want parse error") + } + if !strings.Contains(err.Error(), "kustomization.yaml") { + t.Fatalf("parse error should name the file: %v", err) + } +} + +func TestYK_AlternateKustomizationFilenames(t *testing.T) { + for _, name := range []string{"kustomization.yml", "Kustomization"} { + t.Run(name, func(t *testing.T) { + src := t.TempDir() + seedYKBases(t, src, map[string]string{ + "y-kustomize-bases/kafka/setup-topic-job/x.yaml": "k\n", + }) + kust := `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +secretGenerator: +- name: y-kustomize.kafka.setup-topic-job + files: + - renamed.yaml=y-kustomize-bases/kafka/setup-topic-job/x.yaml +` + os.WriteFile(filepath.Join(src, name), []byte(kust), 0o644) + + _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) + if err == nil || !strings.Contains(err.Error(), "rename syntax") { + t.Fatalf("rename check should run for %s: %v", name, err) + } + }) + } +} From ad102c4a7528c9146f2a8eff0af5a85c35d5c150 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 12:33:50 +0000 Subject: [PATCH 32/43] ci: publish ghcr.io/yolean/y-cluster image via turbokube/contain Follows the pattern from Yolean/ystack@81c49c8 which produced ghcr.io/yolean/y-kustomize: using distroless/static:nonroot as the base and turbokube/contain to assemble the image without a Dockerfile. cmd/y-cluster/contain.yaml: single amd64 binary layered onto the pinned distroless base at /usr/local/bin/y-cluster. Nonroot UID 65532 matches pkg/serve's refuse-root check. .github/workflows/image.yaml: builds and pushes on push-to-main and on v* tag pushes, plus workflow_dispatch. Tags with github.sha always; release tag additionally on v* pushes. `if: github.repository_owner == 'Yolean'` guard is belt-and-braces -- GitHub Actions is not enabled under YoleanAgents, and the human owner mirrors the repo manually when ready to go open source. The guard keeps the workflow a no-op even if Actions is ever turned on there. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/image.yaml | 68 ++++++++++++++++++++++++++++++++++++ cmd/y-cluster/.gitignore | 3 ++ cmd/y-cluster/contain.yaml | 19 ++++++++++ 3 files changed, 90 insertions(+) create mode 100644 .github/workflows/image.yaml create mode 100644 cmd/y-cluster/.gitignore create mode 100644 cmd/y-cluster/contain.yaml diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml new file mode 100644 index 0000000..98b034a --- /dev/null +++ b/.github/workflows/image.yaml @@ -0,0 +1,68 @@ +name: Image + +# Builds and pushes ghcr.io/yolean/y-cluster using turbokube/contain, +# modeled after Yolean/ystack's y-kustomize image workflow +# (ystack@81c49c8). On every push to main and every tag push, the image +# is tagged with the commit SHA; tag pushes also publish the tag name. +# +# GitHub Actions is not enabled under YoleanAgents; the `if:` guard +# below is belt-and-braces so the workflow is a no-op even if it is +# ever enabled there. The human maintainer mirrors from +# YoleanAgents/y-cluster to Yolean/y-cluster when ready to publish. + +on: + push: + branches: [main] + tags: ['v*'] + workflow_dispatch: + +jobs: + image: + if: github.repository_owner == 'Yolean' + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + + - name: Install turbokube/contain + # Pinned to the upstream default branch; update to a released + # tag when turbokube/contain cuts one that covers our feature set. + run: go install github.com/turbokube/contain/cmd/contain@main + + - name: Log in to GitHub Container Registry + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build y-cluster binary (linux/amd64) + working-directory: cmd/y-cluster + env: + CGO_ENABLED: "0" + GOOS: linux + GOARCH: amd64 + run: | + go build -trimpath \ + -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \ + -o target/linux/amd64/y-cluster . + + - name: Build and push image (SHA tag) + working-directory: cmd/y-cluster + env: + IMAGE: ghcr.io/yolean/y-cluster:${{ github.sha }} + run: contain build --push + + - name: Build and push image (release tag) + if: startsWith(github.ref, 'refs/tags/v') + working-directory: cmd/y-cluster + env: + IMAGE: ghcr.io/yolean/y-cluster:${{ github.ref_name }} + run: contain build --push diff --git a/cmd/y-cluster/.gitignore b/cmd/y-cluster/.gitignore new file mode 100644 index 0000000..a557f5c --- /dev/null +++ b/cmd/y-cluster/.gitignore @@ -0,0 +1,3 @@ +y-cluster +target/ +target-oci/ diff --git a/cmd/y-cluster/contain.yaml b/cmd/y-cluster/contain.yaml new file mode 100644 index 0000000..4afdf1c --- /dev/null +++ b/cmd/y-cluster/contain.yaml @@ -0,0 +1,19 @@ +# yaml-language-server: $schema=https://github.com/turbokube/contain/raw/refs/heads/main/jsonschema/config.json +# Image spec for ghcr.io/yolean/y-cluster. Built and pushed by +# .github/workflows/image.yaml using turbokube/contain. +# +# Shape matches ystack's y-kustomize image: distroless static nonroot +# base, single amd64 Go binary layered on top at /usr/local/bin. +# pkg/serve refuses to run as UID 0, so the :nonroot base (UID 65532) +# is what we want. +base: gcr.io/distroless/static:nonroot@sha256:e3f945647ffb95b5839c07038d64f9811adf17308b9121d8a2b87b6a22a80a39 +layers: +- localFile: + path: target/linux/amd64/y-cluster + containerPath: /usr/local/bin/y-cluster + layerAttributes: + uid: 65532 + gid: 65534 + mode: 0755 +entrypoint: +- /usr/local/bin/y-cluster From 67102c60c37c09bf821bdf2bbaff908b6e5e0358 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 12:41:57 +0000 Subject: [PATCH 33/43] docs: release pipeline plan -- build once, multi-arch images, reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Answers the maintainer's question "is main→tag artifact reuse useless?" (largely yes), proposes merging the three workflow files into one, and lays out three options for multi-arch image builds (crane recommended). Five decision points at the end for sign-off before implementation. Co-Authored-By: Claude Opus 4.7 (1M context) From 1aae3443e848622d9d113e5a6ad3cd62308d8f00 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 12:42:19 +0000 Subject: [PATCH 34/43] docs(plan): record D1 answer -- one workflow, one build matrix Also refine the trigger matrix note to spell out that vX.Y.Z image tags are produced only on release runs, not on main pushes. From a538e7b43b6bde0a1737f1463f1283821d052d58 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 12:51:41 +0000 Subject: [PATCH 35/43] docs(plan): record D3-D5 -- raw release assets, no new archs, defer contain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit D3: hand-roll release publishing, but skip compression entirely. Publish each binary under goreleaser's naming convention (y-cluster_vX.Y.Z__) plus a goreleaser-format checksums.txt so downstream tooling that already understands that layout keeps working. Schema file moves from release asset to tagged-source URL. D4: no targets beyond {linux,darwin}×{amd64,arm64} for v0.2.0. D5: defer. Keep cmd/y-cluster/contain.yaml + image.yaml until the filed contain feature request (per-arch localFile.path) is accepted or declined upstream. D2 is likewise blocked on that upstream decision. Feature request written at ~/Yolean/contain/FEATURE_REQUEST_PER_ARCH_LOCALFILE.md (outside this repo) for the maintainer to review. Co-Authored-By: Claude Opus 4.7 (1M context) From 822d21f8e2977f92c8db15c3b387e93f956b592c Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 14:45:41 +0000 Subject: [PATCH 36/43] ci: consolidated pipeline -- one build matrix, multi-arch image, raw release assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces ci.yaml + image.yaml + release.yaml + .goreleaser.yaml with a single workflow that implements the design in RELEASE_PIPELINE_PLAN.md. Binaries are built once per run by a `build` matrix over {linux,darwin} × {amd64,arm64} and uploaded as artifacts. Every downstream job (e2e-serve, image, release-assets) consumes from those artifacts -- so the bytes shipped in the image and the bytes attached to the GitHub release are byte-for-byte the same binaries CI tested. Image job uses turbokube/contain v0.9.0 (pinned by sha256 per ystack's y-bin runner) with the per-arch localFile.pathPerPlatform feature we requested and verified against a test build (ghcr.io/yolean/y-cluster: in a local OCI layout). contain.yaml now declares platforms linux/amd64 and linux/arm64/v8 with a matching pathPerPlatform mapping. The : tag is always published; : is published only on tag pushes. Release-assets job runs on v* tag push and publishes raw (uncompressed) binaries with goreleaser naming (y-cluster___) plus a matching checksums.txt. No tarballs, no .goreleaser.yaml needed -- gh CLI idempotently creates the release and uploads. e2e-release.yaml updated to consume the raw-binary layout and verify the sha256 before running scripts/e2e-serve-against-binary.sh. image + release-assets jobs are guarded on `github.repository_owner == 'Yolean'` so the workflow stays a no-op if ever enabled under YoleanAgents. Human owner mirrors manually when ready to open-source. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yaml | 190 +++++++++++++++++++++++++++-- .github/workflows/e2e-release.yaml | 26 ++-- .github/workflows/image.yaml | 68 ----------- .github/workflows/release.yaml | 30 ----- .goreleaser.yaml | 79 ------------ cmd/y-cluster/contain.yaml | 7 +- 6 files changed, 202 insertions(+), 198 deletions(-) delete mode 100644 .github/workflows/image.yaml delete mode 100644 .github/workflows/release.yaml delete mode 100644 .goreleaser.yaml diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index cc46f44..c73e7bd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,13 +1,44 @@ name: CI +# Consolidated pipeline per RELEASE_PIPELINE_PLAN.md. +# +# One workflow, one `build` matrix, one source of truth for binaries. +# Every consumer (e2e-serve, image, release-assets) pulls from the +# uploaded artifacts so a tag run ships the same bytes everywhere. +# +# Triggers: +# push main → lint, test, build, e2e-serve, image(sha) +# push tag v* → all of the above + image(tag) + release-assets +# pull_request → lint, test, build, e2e-serve only +# workflow_dispatch → same as push main +# +# Image is only published under Yolean (not YoleanAgents) — the `if:` +# on the image/release-assets jobs is belt-and-braces; the human owner +# mirrors manually when ready to open-source. + on: push: branches: [main] + tags: ['v*'] pull_request: - branches: [main] - workflow_call: + workflow_dispatch: jobs: + # --- PR and main checks --- + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version-file: go.mod + cache: true + - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + with: + version: v2.5.0 + args: --timeout=5m + test: runs-on: ubuntu-latest steps: @@ -16,28 +47,165 @@ jobs: with: go-version-file: go.mod cache: true - - run: go test ./... + - run: go test -count=1 ./... - run: go vet ./... - lint: + # --- Single source of truth for y-cluster binaries --- + + build: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: go-version-file: go.mod cache: true - - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + - name: Build y-cluster + env: + CGO_ENABLED: "0" + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + mkdir -p cmd/y-cluster/target/${GOOS}/${GOARCH} + go build -trimpath \ + -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \ + -o cmd/y-cluster/target/${GOOS}/${GOARCH}/y-cluster \ + ./cmd/y-cluster + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - version: v2.5.0 - args: --timeout=5m + name: y-cluster-${{ matrix.goos }}-${{ matrix.goarch }} + path: cmd/y-cluster/target/${{ matrix.goos }}/${{ matrix.goarch }}/y-cluster + retention-days: 7 + if-no-files-found: error + + # --- E2e against the freshly-built linux/amd64 binary --- e2e-serve: runs-on: ubuntu-latest + needs: build steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + - uses: actions/download-artifact@d3f86a106a0bac45b6d05aa4d0c4b1c1d5d3c6c1 # v4.3.0 with: - go-version-file: go.mod - cache: true - - run: go test -tags e2e -count=1 -timeout=5m -run TestServe_ ./e2e/ + name: y-cluster-linux-amd64 + path: bin + - name: Run serve e2e against the built binary + env: + Y_CLUSTER_BIN: ${{ github.workspace }}/bin/y-cluster + run: | + chmod +x "$Y_CLUSTER_BIN" + ./scripts/e2e-serve-against-binary.sh + + # --- Multi-arch image via contain, blocked on all prior jobs --- + + image: + runs-on: ubuntu-latest + needs: [lint, test, build, e2e-serve] + if: github.repository_owner == 'Yolean' && github.event_name != 'pull_request' + permissions: + contents: read + packages: write + env: + CONTAIN_VERSION: "0.9.0" + CONTAIN_SHA256_LINUX_AMD64: "e8c2bbaeb1ff3ddb4adb8a9a87c9a0f1f5b90e2a5899528980e03398c199450b" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - name: Download linux/amd64 binary + uses: actions/download-artifact@d3f86a106a0bac45b6d05aa4d0c4b1c1d5d3c6c1 # v4.3.0 + with: + name: y-cluster-linux-amd64 + path: cmd/y-cluster/target/linux/amd64 + + - name: Download linux/arm64 binary + uses: actions/download-artifact@d3f86a106a0bac45b6d05aa4d0c4b1c1d5d3c6c1 # v4.3.0 + with: + name: y-cluster-linux-arm64 + path: cmd/y-cluster/target/linux/arm64 + + - name: Install turbokube/contain + run: | + set -euo pipefail + url="https://github.com/turbokube/contain/releases/download/v${CONTAIN_VERSION}/contain-v${CONTAIN_VERSION}-linux-amd64" + curl -fsSL -o /usr/local/bin/contain "$url" + echo "${CONTAIN_SHA256_LINUX_AMD64} /usr/local/bin/contain" | sha256sum --check - + chmod +x /usr/local/bin/contain + contain --version + + - name: Log in to GitHub Container Registry + uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image (SHA tag) + working-directory: cmd/y-cluster + env: + IMAGE: ghcr.io/yolean/y-cluster:${{ github.sha }} + run: contain build + + - name: Build and push image (release tag) + if: startsWith(github.ref, 'refs/tags/v') + working-directory: cmd/y-cluster + env: + IMAGE: ghcr.io/yolean/y-cluster:${{ github.ref_name }} + run: contain build + + # --- Release assets on tag push: raw binaries, no compression --- + + release-assets: + runs-on: ubuntu-latest + needs: image + if: startsWith(github.ref, 'refs/tags/v') && github.repository_owner == 'Yolean' + permissions: + contents: write + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: actions/download-artifact@d3f86a106a0bac45b6d05aa4d0c4b1c1d5d3c6c1 # v4.3.0 + with: + pattern: y-cluster-* + path: downloaded + + - name: Rename binaries (goreleaser naming) and checksum + id: assets + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + mkdir -p release + for d in downloaded/y-cluster-*; do + # Each artifact dir contains a single `y-cluster` binary. + base=$(basename "$d") # y-cluster-linux-amd64 + suffix=${base#y-cluster-} # linux-amd64 + os=${suffix%-*} # linux + arch=${suffix#*-} # amd64 + cp "$d/y-cluster" "release/y-cluster_${tag}_${os}_${arch}" + chmod +x "release/y-cluster_${tag}_${os}_${arch}" + done + (cd release && sha256sum y-cluster_${tag}_* > "y-cluster_${tag}_checksums.txt") + ls -l release/ + + - name: Publish release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + tag="${GITHUB_REF_NAME}" + # Idempotent: create the release if it does not yet exist, + # then upload (or re-upload) all assets. + gh release view "$tag" >/dev/null 2>&1 \ + || gh release create "$tag" --title "$tag" --generate-notes --verify-tag + gh release upload "$tag" release/y-cluster_${tag}_* --clobber diff --git a/.github/workflows/e2e-release.yaml b/.github/workflows/e2e-release.yaml index f410f3f..4130e1c 100644 --- a/.github/workflows/e2e-release.yaml +++ b/.github/workflows/e2e-release.yaml @@ -1,8 +1,10 @@ name: e2e Release -# Validates that an actually-published y-cluster release archive works -# end-to-end on the platforms we support. Runs on tag publication and -# can be re-run manually against any published tag. +# Validates that an actually-published y-cluster release asset works +# end-to-end on the platforms we support. Runs on release publication +# and can be re-run manually against any published tag. The asset shape +# is the raw-binary convention from ci.yaml: +# y-cluster___ plus y-cluster__checksums.txt. on: release: @@ -20,9 +22,11 @@ jobs: matrix: include: - os: ubuntu-latest - asset: y-cluster-linux-amd64.tar.gz + os_slug: linux + arch: amd64 - os: macos-latest - asset: y-cluster-darwin-arm64.tar.gz + os_slug: darwin + arch: arm64 runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -37,19 +41,23 @@ jobs: echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT fi - - name: Download release asset + - name: Download release asset and verify checksum shell: bash env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail tag='${{ steps.tag.outputs.tag }}' + asset="y-cluster_${tag}_${{ matrix.os_slug }}_${{ matrix.arch }}" + sums="y-cluster_${tag}_checksums.txt" gh release download "$tag" \ --repo Yolean/y-cluster \ - --pattern '${{ matrix.asset }}' \ + --pattern "$asset" \ + --pattern "$sums" \ --dir . - tar -xzf '${{ matrix.asset }}' - test -x y-cluster + grep " $asset\$" "$sums" | sha256sum --check - + chmod +x "$asset" + mv "$asset" y-cluster ./y-cluster --version - name: Run serve e2e against the release binary diff --git a/.github/workflows/image.yaml b/.github/workflows/image.yaml deleted file mode 100644 index 98b034a..0000000 --- a/.github/workflows/image.yaml +++ /dev/null @@ -1,68 +0,0 @@ -name: Image - -# Builds and pushes ghcr.io/yolean/y-cluster using turbokube/contain, -# modeled after Yolean/ystack's y-kustomize image workflow -# (ystack@81c49c8). On every push to main and every tag push, the image -# is tagged with the commit SHA; tag pushes also publish the tag name. -# -# GitHub Actions is not enabled under YoleanAgents; the `if:` guard -# below is belt-and-braces so the workflow is a no-op even if it is -# ever enabled there. The human maintainer mirrors from -# YoleanAgents/y-cluster to Yolean/y-cluster when ready to publish. - -on: - push: - branches: [main] - tags: ['v*'] - workflow_dispatch: - -jobs: - image: - if: github.repository_owner == 'Yolean' - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - with: - go-version-file: go.mod - cache: true - - - name: Install turbokube/contain - # Pinned to the upstream default branch; update to a released - # tag when turbokube/contain cuts one that covers our feature set. - run: go install github.com/turbokube/contain/cmd/contain@main - - - name: Log in to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 - with: - registry: ghcr.io - username: ${{ github.repository_owner }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build y-cluster binary (linux/amd64) - working-directory: cmd/y-cluster - env: - CGO_ENABLED: "0" - GOOS: linux - GOARCH: amd64 - run: | - go build -trimpath \ - -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \ - -o target/linux/amd64/y-cluster . - - - name: Build and push image (SHA tag) - working-directory: cmd/y-cluster - env: - IMAGE: ghcr.io/yolean/y-cluster:${{ github.sha }} - run: contain build --push - - - name: Build and push image (release tag) - if: startsWith(github.ref, 'refs/tags/v') - working-directory: cmd/y-cluster - env: - IMAGE: ghcr.io/yolean/y-cluster:${{ github.ref_name }} - run: contain build --push diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml deleted file mode 100644 index 9974873..0000000 --- a/.github/workflows/release.yaml +++ /dev/null @@ -1,30 +0,0 @@ -name: Release - -on: - push: - tags: ['*'] - -jobs: - ci: - uses: ./.github/workflows/ci.yaml - - release: - needs: ci - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 0 - - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - with: - go-version-file: go.mod - cache: true - - uses: goreleaser/goreleaser-action@9c156ee8a17a598857849441385a2041ef570552 # v6.3.0 - with: - distribution: goreleaser - version: "~> v2" - args: release --clean - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 14abaa6..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,79 +0,0 @@ -version: 2 - -project_name: y-cluster - -before: - hooks: - - go mod tidy - -builds: - - id: kustomize-traverse - main: . - binary: kustomize-traverse - env: - - CGO_ENABLED=0 - goos: - - linux - - darwin - goarch: - - amd64 - - arm64 - ldflags: - - -s -w - - - id: y-cluster - main: ./cmd/y-cluster - binary: y-cluster - env: - - CGO_ENABLED=0 - goos: - - linux - - darwin - goarch: - - amd64 - - arm64 - ldflags: - - -s -w -X main.version={{.Version}} - -archives: - - id: kustomize-traverse - ids: [kustomize-traverse] - name_template: "kustomize-traverse-{{ .Os }}-{{ .Arch }}" - formats: [tar.gz] - files: - - LICENSE* - - README* - - SPEC* - - - id: y-cluster - ids: [y-cluster] - name_template: "y-cluster-{{ .Os }}-{{ .Arch }}" - formats: [tar.gz] - files: - - LICENSE* - - README* - - SPEC* - - SERVE_FEATURE* - - src: pkg/serve/schema/y-cluster-serve.schema.json - dst: schema - -checksum: - name_template: "checksums-sha256.txt" - algorithm: sha256 - -snapshot: - version_template: "{{ incpatch .Version }}-next" - -changelog: - use: github - sort: asc - filters: - exclude: - - "^docs:" - - "^test:" - - "^chore:" - -release: - github: - owner: Yolean - name: y-cluster diff --git a/cmd/y-cluster/contain.yaml b/cmd/y-cluster/contain.yaml index 4afdf1c..3da97af 100644 --- a/cmd/y-cluster/contain.yaml +++ b/cmd/y-cluster/contain.yaml @@ -7,9 +7,14 @@ # pkg/serve refuses to run as UID 0, so the :nonroot base (UID 65532) # is what we want. base: gcr.io/distroless/static:nonroot@sha256:e3f945647ffb95b5839c07038d64f9811adf17308b9121d8a2b87b6a22a80a39 +platforms: +- linux/amd64 +- linux/arm64/v8 layers: - localFile: - path: target/linux/amd64/y-cluster + pathPerPlatform: + linux/amd64: target/linux/amd64/y-cluster + linux/arm64/v8: target/linux/arm64/y-cluster containerPath: /usr/local/bin/y-cluster layerAttributes: uid: 65532 From f6b77433da2ff2db08734fa8ea4fae991485c2a1 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 14:59:51 +0000 Subject: [PATCH 37/43] ci: use solsson/setup-contain@v1 to install contain Replaces the inline curl + hardcoded sha256 + chmod block with solsson/setup-contain@v1 (pinned to commit 49cc4cc). The action downloads the requested release, verifies its sha256 against the .sha256 asset published next to the binary, handles runner OS and arch, and exports the resolved tag as an output. With this change the workflow no longer has to track contain release checksums -- upstream does, via the .sha256 asset the action fetches on every run. --- .github/workflows/ci.yaml | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c73e7bd..23e4bc6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -117,9 +117,6 @@ jobs: permissions: contents: read packages: write - env: - CONTAIN_VERSION: "0.9.0" - CONTAIN_SHA256_LINUX_AMD64: "e8c2bbaeb1ff3ddb4adb8a9a87c9a0f1f5b90e2a5899528980e03398c199450b" steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 @@ -135,14 +132,9 @@ jobs: name: y-cluster-linux-arm64 path: cmd/y-cluster/target/linux/arm64 - - name: Install turbokube/contain - run: | - set -euo pipefail - url="https://github.com/turbokube/contain/releases/download/v${CONTAIN_VERSION}/contain-v${CONTAIN_VERSION}-linux-amd64" - curl -fsSL -o /usr/local/bin/contain "$url" - echo "${CONTAIN_SHA256_LINUX_AMD64} /usr/local/bin/contain" | sha256sum --check - - chmod +x /usr/local/bin/contain - contain --version + - uses: solsson/setup-contain@49cc4cce1498df8a59e88fff52c1a9b747f11f08 # v1 + with: + version: v0.9.0 - name: Log in to GitHub Container Registry uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 From 45cbdc1d60b7d857d8ade8d088299ba781b9f908 Mon Sep 17 00:00:00 2001 From: Staffan Olsson Date: Fri, 24 Apr 2026 17:19:33 +0200 Subject: [PATCH 38/43] docs: a bit more info about subcommands --- README.md | 63 ++++++++++++++++++++++++++++++------------------------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index c329bf6..d890379 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,38 @@ # y-cluster -Idempotent Kubernetes convergence with dependency ordering and checks. +## Usage + +Use subcommand --help for details. + +``` +# Apply a base with checks +y-cluster yconverge --context=local -k path/to/base/ + +# Check only (no apply) +y-cluster yconverge --context=local --checks-only -k path/to/base/ + +# Print dependency order +y-cluster yconverge --context=local --print-deps -k path/to/base/ + +# Dry run (validate against API server, no mutation) +y-cluster yconverge --context=local --dry-run=server -k path/to/base/ + +# Image management +y-cluster images list -k path/to/base/ +y-cluster images cache -k path/to/base/ +y-cluster images load -k path/to/base/ + +# Cluster provisioning +y-cluster provision --provider=qemu +y-cluster teardown +``` + +## yconverge -## Core concept +Idempotent Kubernetes convergence with dependency ordering and checks. -y-cluster has one fundamental operation: +Symlink y-cluster to `kubectl-yconverge` to add a plugin that can +be used instead of `apply -k`. ``` y-cluster yconverge -k path/to/base/ @@ -13,6 +41,10 @@ y-cluster yconverge -k path/to/base/ This applies a kustomize base to the cluster and runs checks defined in `yconverge.cue` files found in the base's directory tree. +It also supports `yolean.se/converge-mode` labels on +resources in the base, that modify behavior so bases +can be applied with for example a new version of a Job. + Two separate mechanisms control **what gets converged first** and **what gets checked**. Understanding the difference is essential. @@ -123,31 +155,6 @@ This replaces a comma-separated list of targets with a declarative dependency graph. The tool resolves the imports, converges each base in topological order, and exits. -## Usage - -``` -# Apply a base with checks -y-cluster yconverge --context=local -k path/to/base/ - -# Check only (no apply) -y-cluster yconverge --context=local --checks-only -k path/to/base/ - -# Print dependency order -y-cluster yconverge --context=local --print-deps -k path/to/base/ - -# Dry run (validate against API server, no mutation) -y-cluster yconverge --context=local --dry-run=server -k path/to/base/ - -# Image management -y-cluster images list -k path/to/base/ -y-cluster images cache -k path/to/base/ -y-cluster images load -k path/to/base/ - -# Cluster provisioning -y-cluster provision --provider=qemu -y-cluster teardown -``` - ## Check types Checks are defined in `yconverge.cue` next to `kustomization.yaml`: From 91d815c7ad97769a3ebda579078574c9f8ed7c8d Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 15:41:09 +0000 Subject: [PATCH 39/43] ci: fix actions/download-artifact@v4.3.0 commit SHA The previous pin d3f86a106a0bac45b6d05aa4d0c4b1c1d5d3c6c1 was wrong (same leading 8 hex chars as the real SHA, but hallucinated suffix). The real commit SHA for v4.3.0 is d3f86a106a0bac45b974a628896c90dbdf5c8093, confirmed via https://api.github.com/repos/actions/download-artifact/git/refs/tags/v4.3.0. Fixes the e2e-serve, image, and release-assets jobs failing with 'Unable to resolve action'. Other action pins (checkout, setup-go, upload-artifact, golangci-lint-action, docker/login-action, solsson/setup-contain) were re-verified against the same API and are correct. --- .github/workflows/ci.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 23e4bc6..69c0864 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -97,7 +97,7 @@ jobs: needs: build steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/download-artifact@d3f86a106a0bac45b6d05aa4d0c4b1c1d5d3c6c1 # v4.3.0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: y-cluster-linux-amd64 path: bin @@ -121,13 +121,13 @@ jobs: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Download linux/amd64 binary - uses: actions/download-artifact@d3f86a106a0bac45b6d05aa4d0c4b1c1d5d3c6c1 # v4.3.0 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: y-cluster-linux-amd64 path: cmd/y-cluster/target/linux/amd64 - name: Download linux/arm64 binary - uses: actions/download-artifact@d3f86a106a0bac45b6d05aa4d0c4b1c1d5d3c6c1 # v4.3.0 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: y-cluster-linux-arm64 path: cmd/y-cluster/target/linux/arm64 @@ -167,7 +167,7 @@ jobs: steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/download-artifact@d3f86a106a0bac45b6d05aa4d0c4b1c1d5d3c6c1 # v4.3.0 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: pattern: y-cluster-* path: downloaded From da109cc656708f59aa131add67795d80352141c6 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 17:34:01 +0000 Subject: [PATCH 40/43] ci: satisfy errcheck by excluding noise + propagating real errors The CI lint job on run 24902871277 failed with 37 errcheck issues. They fall into two categories: 1. Noise patterns where the returned error is idiomatically ignored: fmt.Fprint*, io.Closer.Close, *os.File.Close, os.Setenv/Unsetenv, os.Remove/RemoveAll. Added these to errcheck.exclude-functions in .golangci.yaml so the signal-to-noise ratio stays useful. 2. Real operations where the error should be propagated: - pkg/kubeconfig/kubeconfig.go cmd.Run: already commented "ignore errors", now explicitly `_ = cmd.Run()`. - pkg/kubeconfig/kubeconfig.go fixNullLists: best-effort rewrite, explicit `_ = os.WriteFile` with a comment explaining why the failure is recoverable (original file remains valid kubectl YAML). - Test helpers calling os.WriteFile / f.WriteString / f.Close in pkg/kubeconfig, pkg/provision/qemu, pkg/serve: wrapped with `if err := ...; err != nil { t.Fatal(err) }` so a seed-file failure fails the test loudly instead of letting a later assertion report a confusing downstream symptom. `golangci-lint run --timeout=5m` now reports 0 issues and `go test ./...` stays green. --- .golangci.yaml | 18 ++++++++++++++++++ pkg/kubeconfig/kubeconfig.go | 7 +++++-- pkg/kubeconfig/kubeconfig_test.go | 16 ++++++++++++---- pkg/provision/qemu/qemu_test.go | 12 +++++++++--- pkg/serve/serve_test.go | 8 ++++++-- pkg/serve/ykustomizelocal_test.go | 30 ++++++++++++++++++++++-------- 6 files changed, 72 insertions(+), 19 deletions(-) diff --git a/.golangci.yaml b/.golangci.yaml index 036c8c0..0933ce0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -8,3 +8,21 @@ linters: - govet - staticcheck - unused + settings: + errcheck: + # Functions whose returned error is idiomatically ignored. + # Real write/exec operations are not excluded -- failures there + # hide bugs. This list is restricted to noise-only patterns. + exclude-functions: + # Writing to stdout/stderr rarely errors meaningfully. + - fmt.Fprint + - fmt.Fprintf + - fmt.Fprintln + # Deferred Close() on files, HTTP response bodies, pipes. + - (io.Closer).Close + - (*os.File).Close + # Test env helpers and best-effort cleanup. + - os.Setenv + - os.Unsetenv + - os.Remove + - os.RemoveAll diff --git a/pkg/kubeconfig/kubeconfig.go b/pkg/kubeconfig/kubeconfig.go index 759775b..1a3df84 100644 --- a/pkg/kubeconfig/kubeconfig.go +++ b/pkg/kubeconfig/kubeconfig.go @@ -53,7 +53,7 @@ func (m *Manager) CleanupStale() { } { cmd := exec.Command("kubectl", args...) cmd.Env = append(os.Environ(), "KUBECONFIG="+m.Path) - cmd.Run() // ignore errors — entries may not exist + _ = cmd.Run() // ignore errors -- entries may not exist } } @@ -130,6 +130,9 @@ func (m *Manager) fixNullLists() { } } if changed { - os.WriteFile(m.Path, []byte(content), 0o600) + // Best-effort rewrite; if it fails the original file is still + // valid YAML (kubectl accepts `contexts: null`), just noisy + // for kubie. Return without raising. + _ = os.WriteFile(m.Path, []byte(content), 0o600) } } diff --git a/pkg/kubeconfig/kubeconfig_test.go b/pkg/kubeconfig/kubeconfig_test.go index d165f82..ce32d00 100644 --- a/pkg/kubeconfig/kubeconfig_test.go +++ b/pkg/kubeconfig/kubeconfig_test.go @@ -44,7 +44,9 @@ contexts: null kind: Config users: null ` - os.WriteFile(path, []byte(content), 0o600) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } m := &Manager{Path: path} m.fixNullLists() @@ -78,7 +80,9 @@ contexts: [] kind: Config users: [] ` - os.WriteFile(path, []byte(content), 0o600) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } m := &Manager{Path: path} m.fixNullLists() @@ -164,7 +168,9 @@ users: - name: prod user: {} ` - os.WriteFile(path, []byte(existing), 0o600) + if err := os.WriteFile(path, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } os.Setenv("KUBECONFIG", path) defer os.Unsetenv("KUBECONFIG") @@ -210,7 +216,9 @@ users: func TestCleanupStale_NoError(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "kubeconfig") - os.WriteFile(path, []byte("apiVersion: v1\nkind: Config\nclusters: []\ncontexts: []\nusers: []\n"), 0o600) + if err := os.WriteFile(path, []byte("apiVersion: v1\nkind: Config\nclusters: []\ncontexts: []\nusers: []\n"), 0o600); err != nil { + t.Fatal(err) + } os.Setenv("KUBECONFIG", path) defer os.Unsetenv("KUBECONFIG") diff --git a/pkg/provision/qemu/qemu_test.go b/pkg/provision/qemu/qemu_test.go index 1b88ab2..8983602 100644 --- a/pkg/provision/qemu/qemu_test.go +++ b/pkg/provision/qemu/qemu_test.go @@ -39,7 +39,9 @@ func TestIsRunning_StalePidFile(t *testing.T) { cfg.CacheDir = t.TempDir() // Write a pid file with a non-existent PID pidFile := filepath.Join(cfg.CacheDir, cfg.Name+".pid") - os.WriteFile(pidFile, []byte("999999999\n"), 0o644) + if err := os.WriteFile(pidFile, []byte("999999999\n"), 0o644); err != nil { + t.Fatal(err) + } running, _ := cfg.IsRunning() if running { t.Fatal("expected not running for stale pid") @@ -75,7 +77,9 @@ func TestTeardownConfig_KeepDisk(t *testing.T) { cfg.CacheDir = t.TempDir() cfg.Kubeconfig = "" diskPath := filepath.Join(cfg.CacheDir, cfg.Name+".qcow2") - os.WriteFile(diskPath, []byte("fake"), 0o644) + if err := os.WriteFile(diskPath, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } if err := TeardownConfig(cfg, true, nil); err != nil { t.Fatal(err) @@ -91,7 +95,9 @@ func TestTeardownConfig_DeleteDisk(t *testing.T) { cfg.CacheDir = t.TempDir() cfg.Kubeconfig = "" diskPath := filepath.Join(cfg.CacheDir, cfg.Name+".qcow2") - os.WriteFile(diskPath, []byte("fake"), 0o644) + if err := os.WriteFile(diskPath, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } if err := TeardownConfig(cfg, false, nil); err != nil { t.Fatal(err) diff --git a/pkg/serve/serve_test.go b/pkg/serve/serve_test.go index 08a86cb..0791d84 100644 --- a/pkg/serve/serve_test.go +++ b/pkg/serve/serve_test.go @@ -335,8 +335,12 @@ func TestLogs_Follow(t *testing.T) { if err != nil { t.Fatal(err) } - f.WriteString("second\n") - f.Close() + if _, err := f.WriteString("second\n"); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } if err := <-done; err != nil { t.Fatal(err) diff --git a/pkg/serve/ykustomizelocal_test.go b/pkg/serve/ykustomizelocal_test.go index add253a..0d9b4ca 100644 --- a/pkg/serve/ykustomizelocal_test.go +++ b/pkg/serve/ykustomizelocal_test.go @@ -101,7 +101,9 @@ func TestYK_MissingBasesDir(t *testing.T) { func TestYK_SourceIsFile(t *testing.T) { f := filepath.Join(t.TempDir(), "file") - os.WriteFile(f, []byte("x"), 0o644) + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } _, err := newYKustomizeLocalBackend(cfgWithSources(t, f)) if err == nil || !strings.Contains(err.Error(), "not a directory") { t.Fatalf("want not-a-directory error, got %v", err) @@ -110,7 +112,9 @@ func TestYK_SourceIsFile(t *testing.T) { func TestYK_BasesIsFile(t *testing.T) { src := t.TempDir() - os.WriteFile(filepath.Join(src, "y-kustomize-bases"), []byte("x"), 0o644) + if err := os.WriteFile(filepath.Join(src, "y-kustomize-bases"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) if err == nil || !strings.Contains(err.Error(), "not a directory") { t.Fatalf("want not-a-directory error, got %v", err) @@ -245,7 +249,9 @@ secretGenerator: files: - base-for-annotations.yaml=y-kustomize-bases/kafka/setup-topic-job/setup-topic-job.yaml ` - os.WriteFile(filepath.Join(src, "kustomization.yaml"), []byte(kust), 0o644) + if err := os.WriteFile(filepath.Join(src, "kustomization.yaml"), []byte(kust), 0o644); err != nil { + t.Fatal(err) + } _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) if err == nil { @@ -269,7 +275,9 @@ secretGenerator: files: - y-kustomize-bases/blobs/setup-bucket-job/base-for-annotations.yaml ` - os.WriteFile(filepath.Join(src, "kustomization.yaml"), []byte(kust), 0o644) + if err := os.WriteFile(filepath.Join(src, "kustomization.yaml"), []byte(kust), 0o644); err != nil { + t.Fatal(err) + } b, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) if err != nil { @@ -307,7 +315,9 @@ configMapGenerator: files: - base-for-annotations.yaml=y-kustomize-bases/kafka/setup-topic-job/setup-topic-job.yaml ` - os.WriteFile(filepath.Join(src, "kustomization.yaml"), []byte(kust), 0o644) + if err := os.WriteFile(filepath.Join(src, "kustomization.yaml"), []byte(kust), 0o644); err != nil { + t.Fatal(err) + } _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) if err == nil || !strings.Contains(err.Error(), "rename syntax") { @@ -324,8 +334,10 @@ func TestYK_MalformedKustomizationRejected(t *testing.T) { "y-kustomize-bases/blobs/setup-bucket-job/values.yaml": "k\n", }) // Unclosed list → yaml parse error - os.WriteFile(filepath.Join(src, "kustomization.yaml"), - []byte("secretGenerator:\n- name: x\n files: [unclosed\n"), 0o644) + if err := os.WriteFile(filepath.Join(src, "kustomization.yaml"), + []byte("secretGenerator:\n- name: x\n files: [unclosed\n"), 0o644); err != nil { + t.Fatal(err) + } _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) if err == nil { @@ -350,7 +362,9 @@ secretGenerator: files: - renamed.yaml=y-kustomize-bases/kafka/setup-topic-job/x.yaml ` - os.WriteFile(filepath.Join(src, name), []byte(kust), 0o644) + if err := os.WriteFile(filepath.Join(src, name), []byte(kust), 0o644); err != nil { + t.Fatal(err) + } _, err := newYKustomizeLocalBackend(cfgWithSources(t, src)) if err == nil || !strings.Contains(err.Error(), "rename syntax") { From e62cb0ca5168b0a2c655b86eeb5edb8d9b820fb8 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 17:58:24 +0000 Subject: [PATCH 41/43] ci: upgrade to golangci-lint-action v9.2.0 and golangci-lint v2.11.4 Addresses the lint failure observed on run 24903224830. The action's default install-mode fetched the prebuilt golangci-lint v2.5.0 binary, which was compiled with Go 1.25 and refused to load our config with: can't load config: the Go language version (go1.25) used to build golangci-lint is lower than the targeted Go version (1.26.1) Two coupled upgrades: 1. golangci-lint-action v8.0.0 -> v9.2.0. v9 runs on node24, which eliminates the Node.js 20 deprecation warnings GitHub currently prints on every job (Node 20 becomes unsupported June 2026). Same input surface as v8; no other config changes needed. 2. golangci-lint v2.5.0 -> v2.11.4. v2.11.4 is the latest release (March 2026). Its release binary is built with Go 1.26 per the upstream release.yml (GO_VERSION: "1.26" + goreleaser), so the default prebuilt binary install works with our go 1.26.1 module. No install-mode: goinstall workaround needed. Drop-through fix in pkg/serve/openapi.go: v2.11.4's staticcheck enables QF1012, which flags b.WriteString(fmt.Sprintf(...)) as replaceable by fmt.Fprintf(&b, ...). Three call sites rewritten; behavior identical. If go.mod is ever bumped ahead of what golangci-lint's latest release supports, re-add "install-mode: goinstall" on the lint step to rebuild golangci-lint from source with the runner's Go. --- .github/workflows/ci.yaml | 4 ++-- pkg/serve/openapi.go | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 69c0864..a0e0a1b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,9 +34,9 @@ jobs: with: go-version-file: go.mod cache: true - - uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0 + - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 with: - version: v2.5.0 + version: v2.11.4 args: --timeout=5m test: diff --git a/pkg/serve/openapi.go b/pkg/serve/openapi.go index c1399bf..26e8d64 100644 --- a/pkg/serve/openapi.go +++ b/pkg/serve/openapi.go @@ -31,17 +31,17 @@ func (s openAPISpec) Render() []byte { var b bytes.Buffer b.WriteString("openapi: 3.1.0\n") b.WriteString("info:\n") - b.WriteString(fmt.Sprintf(" title: %s\n", yamlEscape(s.Title))) - b.WriteString(fmt.Sprintf(" x-type: %s\n", string(s.Type))) - b.WriteString(fmt.Sprintf(" version: %s\n", yamlEscape(s.Version))) + fmt.Fprintf(&b, " title: %s\n", yamlEscape(s.Title)) + fmt.Fprintf(&b, " x-type: %s\n", string(s.Type)) + fmt.Fprintf(&b, " version: %s\n", yamlEscape(s.Version)) b.WriteString("paths:\n") for _, r := range s.Routes { - b.WriteString(fmt.Sprintf(" %s:\n", yamlEscape(r.Path))) + fmt.Fprintf(&b, " %s:\n", yamlEscape(r.Path)) b.WriteString(" get:\n") b.WriteString(" responses:\n") b.WriteString(" \"200\":\n") b.WriteString(" content:\n") - b.WriteString(fmt.Sprintf(" %s: {}\n", yamlEscape(r.ContentType))) + fmt.Fprintf(&b, " %s: {}\n", yamlEscape(r.ContentType)) } return b.Bytes() } From 303cbe86b2f268bb01009b736e260c43337ed886 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 18:10:51 +0000 Subject: [PATCH 42/43] ci: bump all GitHub-maintained actions to Node.js 24 releases Run e62cb0c's green build still emits Node.js 20 deprecation warnings because the runtimes of our other action pins are still node20. June 2nd, 2026 the runner starts forcing node24 by default, and node20 leaves the runners entirely on September 16th, 2026. Getting ahead of both dates so the warnings vanish and the pinned SHAs stay valid after the flip. Version bumps (all pinned by commit SHA, same convention as before): actions/checkout v4.3.1 -> v6.0.2 actions/setup-go v5.5.0 -> v6.4.0 actions/upload-artifact v4.6.2 -> v7.0.1 actions/download-artifact v4.3.0 -> v8.0.1 docker/login-action v3.4.0 -> v4.1.0 Each of the new versions declares `runs.using: node24`, verified by fetching action.yml at the corresponding tag. solsson/setup-contain is a composite action and does not have a node runtime concern. Input surfaces are unchanged for the way we call these actions (go-version-file, cache, name, path, pattern, if-no-files-found, registry/username/password). No behavior change expected. --- .github/workflows/ci.yaml | 30 +++++++++++++++--------------- .github/workflows/e2e-release.yaml | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index a0e0a1b..19aae85 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -29,8 +29,8 @@ jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod cache: true @@ -42,8 +42,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod cache: true @@ -67,8 +67,8 @@ jobs: - goos: darwin goarch: arm64 steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 with: go-version-file: go.mod cache: true @@ -83,7 +83,7 @@ jobs: -ldflags "-s -w -X main.version=${GITHUB_REF_NAME}" \ -o cmd/y-cluster/target/${GOOS}/${GOARCH}/y-cluster \ ./cmd/y-cluster - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: y-cluster-${{ matrix.goos }}-${{ matrix.goarch }} path: cmd/y-cluster/target/${{ matrix.goos }}/${{ matrix.goarch }}/y-cluster @@ -96,8 +96,8 @@ jobs: runs-on: ubuntu-latest needs: build steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: y-cluster-linux-amd64 path: bin @@ -118,16 +118,16 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Download linux/amd64 binary - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: y-cluster-linux-amd64 path: cmd/y-cluster/target/linux/amd64 - name: Download linux/arm64 binary - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: y-cluster-linux-arm64 path: cmd/y-cluster/target/linux/arm64 @@ -137,7 +137,7 @@ jobs: version: v0.9.0 - name: Log in to GitHub Container Registry - uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -165,9 +165,9 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: pattern: y-cluster-* path: downloaded diff --git a/.github/workflows/e2e-release.yaml b/.github/workflows/e2e-release.yaml index 4130e1c..29f7b27 100644 --- a/.github/workflows/e2e-release.yaml +++ b/.github/workflows/e2e-release.yaml @@ -29,7 +29,7 @@ jobs: arch: arm64 runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Resolve tag id: tag From 208b8c91a683bed0d04b09eda59ef797a9b4e312 Mon Sep 17 00:00:00 2001 From: Yolean k8s-qa Date: Fri, 24 Apr 2026 20:08:01 +0000 Subject: [PATCH 43/43] feat(serve): implement y-kustomize-in-cluster and static backends Two backends ship together because they shared the runtime plumbing (dynamic health, dynamic openapi, context-driven backend lifetimes) and because the ystack migration is the first production user of the in-cluster backend. y-kustomize-in-cluster (new): - Watches Kubernetes Secrets named y-kustomize.{group}.{name} via a SharedInformer and serves each data key at /v1/{group}/{name}/{key}. Matches the ystack y-kustomize convention exactly so consumers need no changes. - Content-Type is application/yaml (RFC 9512, per Q-S3), replacing ystack's legacy application/x-yaml. - Label selector defaults to yolean.se/module-part=y-kustomize (ystack convention) and is overridable via inCluster.labelSelector. - Namespace resolution: explicit config -> pod's service account namespace file -> kubeconfig current-context namespace -> "default". - openapi.yaml is re-rendered on every request so it adapts to the current watch state; /health likewise reports live routes+ns+selector. - client-go dep (k8s.io/client-go v0.35.4) is pulled in; the fake clientset drives all unit tests for watch semantics. static (was a stub, now implemented): - Snapshots the configured dir at startup for the openapi spec. - Path traversal is gated twice: URL path cleaning + an absolute-path prefix check against the resolved dir. - dirTrailingSlash "redirect" emits 302 to the trailing-slash form (query string preserved); target still 404s -- no listing. - yamlToJson transforms application/yaml responses to application/json only when the override is on. Minification via sigs.k8s.io/yaml + re-marshal; Content-Length and ETag are computed on the transformed body; HEAD runs the transform too so headers agree. 500 on parse failure. Off by default. - The openapi spec advertises application/json for .yaml routes when yamlToJson is enabled. Runtime plumbing: - buildServers now takes ctx so backends with background goroutines (informer) can tie their lifetime to SIGTERM. - health.HealthHandlerFunc and openapi.OpenAPIHandlerFunc invoke their provider on every request; the existing HealthHandler now snapshots its map once to keep backward compatibility. - http.WriteAssetAs factors out the Content-Type-overriding path so yamlToJson can reuse ETag/Cache-Control/If-None-Match logic. E2E additions: - testdata/serve-static/ -- a worked example with yamlToJson enabled and dirTrailingSlash=redirect. TestServe_Static drives the binary end-to-end. - testdata/serve-ykustomize-incluster/ -- config + a Secret manifest matching the ystack shape. TestServe_InCluster runs against the shared kwok cluster from yconverge e2e tests: apply, serve, patch, delete, assert the watch propagates at each step. Tests: 0 golangci-lint issues, all go test -count=1 ./... green, all go test -tags e2e ./e2e/ green including the new in-cluster and static scenarios. --- e2e/serve_test.go | 264 ++++++++++- go.mod | 33 +- go.sum | 80 +++- pkg/serve/config.go | 59 ++- pkg/serve/health.go | 20 +- pkg/serve/http.go | 15 +- pkg/serve/kubeclient.go | 78 ++++ pkg/serve/openapi.go | 14 +- pkg/serve/process.go | 60 ++- pkg/serve/schema/y-cluster-serve.schema.json | 19 +- pkg/serve/serve.go | 4 +- pkg/serve/serve_test.go | 15 +- pkg/serve/static.go | 237 +++++++++- pkg/serve/static_test.go | 393 ++++++++++++++++ pkg/serve/ykustomizeincluster.go | 271 +++++++++++ pkg/serve/ykustomizeincluster_test.go | 441 ++++++++++++++++++ .../serve-static/config/y-cluster-serve.yaml | 8 + testdata/serve-static/files/README.txt | 4 + .../serve-static/files/greetings/hello.yaml | 3 + .../config/y-cluster-serve.yaml | 13 + .../secrets/blobs.yaml | 26 ++ 21 files changed, 1981 insertions(+), 76 deletions(-) create mode 100644 pkg/serve/kubeclient.go create mode 100644 pkg/serve/static_test.go create mode 100644 pkg/serve/ykustomizeincluster.go create mode 100644 pkg/serve/ykustomizeincluster_test.go create mode 100644 testdata/serve-static/config/y-cluster-serve.yaml create mode 100644 testdata/serve-static/files/README.txt create mode 100644 testdata/serve-static/files/greetings/hello.yaml create mode 100644 testdata/serve-ykustomize-incluster/config/y-cluster-serve.yaml create mode 100644 testdata/serve-ykustomize-incluster/secrets/blobs.yaml diff --git a/e2e/serve_test.go b/e2e/serve_test.go index ea5547d..93559c6 100644 --- a/e2e/serve_test.go +++ b/e2e/serve_test.go @@ -71,12 +71,12 @@ func freePort(t *testing.T) int { return port } -// prepareFixture copies testdata/serve-ykustomize-local/ into a temp dir -// and substitutes __PORT__ in y-cluster-serve.yaml. Returns the absolute +// prepareFixture copies a testdata// tree into a temp dir and +// substitutes __PORT__ in y-cluster-serve.yaml. Returns the absolute // path of the prepared config directory. -func prepareFixture(t *testing.T, port int) string { +func prepareFixture(t *testing.T, name string, port int) string { t.Helper() - src, err := filepath.Abs("../testdata/serve-ykustomize-local") + src, err := filepath.Abs(filepath.Join("../testdata", name)) if err != nil { t.Fatal(err) } @@ -129,7 +129,7 @@ func runServe(t *testing.T, bin, stateDir string, args ...string) ([]byte, error func TestServe_EnsureRoundtrip(t *testing.T) { bin := buildServeBinary(t) port := freePort(t) - cfgDir := prepareFixture(t, port) + cfgDir := prepareFixture(t, "serve-ykustomize-local", port) stateDir := t.TempDir() // 1. ensure → daemon starts, /health 200 on the configured port @@ -225,7 +225,7 @@ func TestServe_EnsureRoundtrip(t *testing.T) { func TestServe_LogsSubcommand(t *testing.T) { bin := buildServeBinary(t) port := freePort(t) - cfgDir := prepareFixture(t, port) + cfgDir := prepareFixture(t, "serve-ykustomize-local", port) stateDir := t.TempDir() if out, err := runServe(t, bin, stateDir, "serve", "ensure", "-c", cfgDir); err != nil { @@ -311,3 +311,255 @@ func retryGET(url string) (*http.Response, error) { } return nil, last } + +// TestServe_InCluster covers the y-kustomize-in-cluster backend +// against a real kwok cluster. The test plays out the workflow that +// will replace ystack's y-kustomize deployment: apply a Secret with +// the y-kustomize convention, serve it, mutate it, verify the watch +// propagates, then delete it and verify the route disappears. +// +// The fixture files also serve as documentation for ystack migration; +// see docs/ystack-migration.md on the spec branch. +func TestServe_InCluster(t *testing.T) { + setupCluster(t) + bin := buildServeBinary(t) + port := freePort(t) + + // Prepare the fixture with kubeconfig + port substituted. + src, err := filepath.Abs("../testdata/serve-ykustomize-incluster") + if err != nil { + t.Fatal(err) + } + dst := t.TempDir() + if err := copyTree(src, dst); err != nil { + t.Fatal(err) + } + cfgPath := filepath.Join(dst, "config", "y-cluster-serve.yaml") + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + rendered := strings.ReplaceAll(string(data), "__PORT__", fmt.Sprintf("%d", port)) + rendered = strings.ReplaceAll(rendered, "__KUBECONFIG__", clusterKubeconfig) + if err := os.WriteFile(cfgPath, []byte(rendered), 0o644); err != nil { + t.Fatal(err) + } + cfgDir := filepath.Join(dst, "config") + + // Clean slate: remove the Secret if a previous run left it behind. + secretName := "y-kustomize.blobs.setup-bucket-job" + _ = exec.Command("kubectl", "--context="+contextName, "delete", "secret", secretName, + "--ignore-not-found=true", "--namespace=default").Run() + + // Apply the initial Secret. This is the same manifest a + // ystack-style module would ship. + secretPath := filepath.Join(dst, "secrets", "blobs.yaml") + apply := exec.Command("kubectl", "--context="+contextName, "apply", "-f", secretPath) + apply.Env = append(os.Environ(), "KUBECONFIG="+clusterKubeconfig) + if out, err := apply.CombinedOutput(); err != nil { + t.Fatalf("apply initial secret: %s: %v", out, err) + } + t.Cleanup(func() { + cleanup := exec.Command("kubectl", "--context="+contextName, "delete", "secret", secretName, + "--ignore-not-found=true", "--namespace=default") + cleanup.Env = append(os.Environ(), "KUBECONFIG="+clusterKubeconfig) + _ = cleanup.Run() + }) + + stateDir := t.TempDir() + if out, err := runServe(t, bin, stateDir, "serve", "ensure", "-c", cfgDir); err != nil { + t.Fatalf("ensure: %v\n%s", err, out) + } + t.Cleanup(func() { + _, _ = runServe(t, bin, stateDir, "serve", "stop", "--state-dir", stateDir) + }) + + // /health reports the namespace and selector + current route count. + body, _, err := httpGet(fmt.Sprintf("http://127.0.0.1:%d/health", port)) + if err != nil { + t.Fatalf("health: %v", err) + } + var h map[string]any + if err := json.Unmarshal(body, &h); err != nil { + t.Fatalf("health JSON: %v", err) + } + if h["namespace"] != "default" { + t.Fatalf("health.namespace: %v", h["namespace"]) + } + if h["routes"].(float64) < 1 { + t.Fatalf("health.routes: %v", h["routes"]) + } + + // Known route from the applied Secret. Wait up to a few seconds: + // the y-cluster daemon started before kubectl apply took effect + // for the watch. + routeURL := fmt.Sprintf("http://127.0.0.1:%d/v1/blobs/setup-bucket-job/base-for-annotations.yaml", port) + deadline := time.Now().Add(5 * time.Second) + for { + resp, err := http.Get(routeURL) + if err == nil && resp.StatusCode == 200 { + resp.Body.Close() + break + } + if resp != nil { + resp.Body.Close() + } + if time.Now().After(deadline) { + t.Fatalf("route never appeared: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + + body, hdr, err := httpGet(routeURL) + if err != nil { + t.Fatalf("GET route: %v", err) + } + if !strings.Contains(string(body), "setup-bucket-job") { + t.Fatalf("body missing marker: %q", body) + } + if ct := hdr.Get("Content-Type"); !strings.HasPrefix(ct, "application/yaml") { + t.Fatalf("content-type: got %q, want application/yaml", ct) + } + + // The second data key in the Secret is served as its own route. + valuesURL := fmt.Sprintf("http://127.0.0.1:%d/v1/blobs/setup-bucket-job/values.yaml", port) + body, _, err = httpGet(valuesURL) + if err != nil { + t.Fatalf("GET values.yaml: %v", err) + } + if !strings.Contains(string(body), "bucket: builds") { + t.Fatalf("values body: %q", body) + } + + // openapi reflects the current watch state (SERVE_FEATURE.md says + // the spec adapts to the watch -- rendered on every request). + oa, _, err := httpGet(fmt.Sprintf("http://127.0.0.1:%d/openapi.yaml", port)) + if err != nil { + t.Fatalf("openapi: %v", err) + } + if !strings.Contains(string(oa), "/v1/blobs/setup-bucket-job/base-for-annotations.yaml") { + t.Fatalf("openapi missing route: %s", oa) + } + + // Mutate the Secret's values.yaml; watch should propagate. + patch := `{"stringData":{"values.yaml":"bucket: builds-v2\n"}}` + p := exec.Command("kubectl", "--context="+contextName, "patch", "secret", secretName, + "--namespace=default", "--type=merge", "-p", patch) + p.Env = append(os.Environ(), "KUBECONFIG="+clusterKubeconfig) + if out, err := p.CombinedOutput(); err != nil { + t.Fatalf("patch: %s: %v", out, err) + } + + deadline = time.Now().Add(5 * time.Second) + for { + body, _, err := httpGet(valuesURL) + if err == nil && strings.Contains(string(body), "builds-v2") { + break + } + if time.Now().After(deadline) { + t.Fatalf("patched body never propagated: %v", err) + } + time.Sleep(100 * time.Millisecond) + } + + // Delete the Secret; route should 404 shortly. + d := exec.Command("kubectl", "--context="+contextName, "delete", "secret", secretName, + "--namespace=default") + d.Env = append(os.Environ(), "KUBECONFIG="+clusterKubeconfig) + if out, err := d.CombinedOutput(); err != nil { + t.Fatalf("delete: %s: %v", out, err) + } + + deadline = time.Now().Add(5 * time.Second) + for { + resp, err := http.Get(routeURL) + if err == nil && resp.StatusCode == 404 { + resp.Body.Close() + return + } + if resp != nil { + resp.Body.Close() + } + if time.Now().After(deadline) { + t.Fatalf("route never removed after delete") + } + time.Sleep(100 * time.Millisecond) + } +} + +// TestServe_Static covers the static backend end to end: yamlToJson +// transform, dirTrailingSlash=redirect, and openapi snapshot. Uses +// testdata/serve-static/ as the worked example. +func TestServe_Static(t *testing.T) { + bin := buildServeBinary(t) + port := freePort(t) + cfgDir := prepareFixture(t, "serve-static", port) + stateDir := t.TempDir() + + if out, err := runServe(t, bin, stateDir, "serve", "ensure", "-c", cfgDir); err != nil { + t.Fatalf("ensure: %v\n%s", err, out) + } + t.Cleanup(func() { + _, _ = runServe(t, bin, stateDir, "serve", "stop", "--state-dir", stateDir) + }) + + if err := httpGetStatus(fmt.Sprintf("http://127.0.0.1:%d/health", port)); err != nil { + t.Fatalf("health: %v", err) + } + + // yamlToJson path: hello.yaml is served transformed. + body, hdr, err := httpGet(fmt.Sprintf("http://127.0.0.1:%d/assets/greetings/hello.yaml", port)) + if err != nil { + t.Fatalf("GET hello.yaml: %v", err) + } + if ct := hdr.Get("Content-Type"); ct != "application/json" { + t.Fatalf("content-type: got %q, want application/json", ct) + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("transformed body is not json: %v: %s", err, body) + } + if strings.Contains(string(body), " ") { + t.Fatalf("expected minified json, got %q", body) + } + + // Non-yaml passes through unchanged. + body, hdr, err = httpGet(fmt.Sprintf("http://127.0.0.1:%d/assets/README.txt", port)) + if err != nil { + t.Fatalf("GET README.txt: %v", err) + } + if !strings.HasPrefix(hdr.Get("Content-Type"), "text/plain") { + t.Fatalf("txt content-type: %s", hdr.Get("Content-Type")) + } + if !strings.Contains(string(body), "served by y-cluster serve") { + t.Fatalf("text body: %q", body) + } + + // dirTrailingSlash=redirect: hitting a directory without the + // trailing slash redirects, query string preserved. + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/assets/greetings?pick=latest", port)) + if err != nil { + t.Fatalf("GET dir: %v", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("dir redirect: got %d, want 302", resp.StatusCode) + } + if loc := resp.Header.Get("Location"); loc != "/assets/greetings/?pick=latest" { + t.Fatalf("Location: %q", loc) + } + + // openapi lists routes, with content-type reflecting the transform + // (hello.yaml shows application/json, README.txt shows text/plain). + oa, _, err := httpGet(fmt.Sprintf("http://127.0.0.1:%d/openapi.yaml", port)) + if err != nil { + t.Fatalf("openapi: %v", err) + } + if !strings.Contains(string(oa), "/assets/greetings/hello.yaml") { + t.Fatalf("openapi missing hello.yaml: %s", oa) + } + if !strings.Contains(string(oa), "application/json") { + t.Fatalf("openapi should advertise json for transformed yaml: %s", oa) + } +} diff --git a/go.mod b/go.mod index 3fcde3a..d84dbbd 100644 --- a/go.mod +++ b/go.mod @@ -6,41 +6,62 @@ require ( cuelang.org/go v0.16.1 github.com/spf13/cobra v1.10.2 go.uber.org/zap v1.27.1 + k8s.io/api v0.36.0 + k8s.io/apimachinery v0.36.0 + k8s.io/client-go v0.36.0 sigs.k8s.io/kustomize/api v0.21.1 - sigs.k8s.io/yaml v1.5.0 + sigs.k8s.io/yaml v1.6.0 ) require ( cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emicklei/proto v1.14.3 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-errors/errors v1.4.2 // 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.6.9 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.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/mitchellh/go-wordwrap v1.0.1 // 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/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/x448/float16 v0.8.4 // indirect go.uber.org/multierr v1.10.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect golang.org/x/text v0.35.0 // indirect - google.golang.org/protobuf v1.36.5 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // 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/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect ) diff --git a/go.sum b/go.sum index eb5bf6c..8682802 100644 --- a/go.sum +++ b/go.sum @@ -7,12 +7,19 @@ github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065na github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/emicklei/proto v1.14.3 h1:zEhlzNkpP8kN6utonKMzlPfIvy82t5Kb9mufaJxSe1Q= github.com/emicklei/proto v1.14.3/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +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-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +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= @@ -23,18 +30,19 @@ github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+Gr github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-quicktest/qt v1.101.0 h1:O1K29Txy5P2OK0dGo59b7b0LR6wKfIhttaAhHUyn7eI= github.com/go-quicktest/qt v1.101.0/go.mod h1:14Bz/f7NwaXPtdYEgzsx46kqSxVwTbzVZsDC26tQJow= -github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= -github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= +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.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= -github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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= @@ -50,14 +58,23 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +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/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94 h1:2PC6Ql3jipz1KvBlqUHjjk6v4aMwE86mfDu1XMH0LR8= github.com/protocolbuffers/txtpbfmt v0.0.0-20260217160748-a481f6a22f94/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -71,19 +88,24 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= @@ -96,25 +118,47 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= -google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= -google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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/kube-openapi v0.0.0-20241212222426-2c72e554b1e7 h1:hcha5B1kVACrLujCKLbr8XWMxCxzQx42DY8QKYJrDLg= -k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= -sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= +k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= +k8s.io/api v0.36.0/go.mod h1:m1LVrGPNYax5NBHdO+QuAedXyuzTt4RryI/qnmNvs34= +k8s.io/apimachinery v0.36.0 h1:jZyPzhd5Z+3h9vJLt0z9XdzW9VzNzWAUw+P1xZ9PXtQ= +k8s.io/apimachinery v0.36.0/go.mod h1:FklypaRJt6n5wUIwWXIP6GJlIpUizTgfo1T/As+Tyxc= +k8s.io/client-go v0.36.0 h1:pOYi7C4RHChYjMiHpZSpSbIM6ZxVbRXBy7CuiIwqA3c= +k8s.io/client-go v0.36.0/go.mod h1:ZKKcpwF0aLYfkHFCjillCKaTK/yBkEDHTDXCFY6AS9Y= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +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/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= -sigs.k8s.io/yaml v1.5.0 h1:M10b2U7aEUY6hRtU870n2VTPgR5RZiL/I6Lcc2F4NUQ= -sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= +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.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/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/pkg/serve/config.go b/pkg/serve/config.go index 08a3b09..bc5bb81 100644 --- a/pkg/serve/config.go +++ b/pkg/serve/config.go @@ -22,8 +22,9 @@ const ConfigFilename = "y-cluster-serve.yaml" type BackendType string const ( - TypeYKustomizeLocal BackendType = "y-kustomize-local" - TypeStatic BackendType = "static" + TypeYKustomizeLocal BackendType = "y-kustomize-local" + TypeYKustomizeInCluster BackendType = "y-kustomize-in-cluster" + TypeStatic BackendType = "static" ) // Config is the parsed y-cluster-serve.yaml plus the directory it came from. @@ -32,10 +33,11 @@ type Config struct { // declared relative in YAML resolve against this. Dir string `json:"dir"` - Port int `json:"port" yaml:"port"` - Type BackendType `json:"type" yaml:"type"` - Static *StaticConfig `json:"static,omitempty" yaml:"static,omitempty"` - Sources []YKustomizeLocalSource `json:"sources,omitempty" yaml:"sources,omitempty"` + Port int `json:"port" yaml:"port"` + Type BackendType `json:"type" yaml:"type"` + Static *StaticConfig `json:"static,omitempty" yaml:"static,omitempty"` + Sources []YKustomizeLocalSource `json:"sources,omitempty" yaml:"sources,omitempty"` + InCluster *YKustomizeInClusterConfig `json:"inCluster,omitempty" yaml:"inCluster,omitempty"` } // StaticConfig is declared so the schema round-trips, but the runtime @@ -52,6 +54,31 @@ type YKustomizeLocalSource struct { Dir string `json:"dir" yaml:"dir"` } +// YKustomizeInClusterConfig configures type y-kustomize-in-cluster: a +// backend that serves y-kustomize bases by watching Kubernetes +// Secrets named `y-kustomize.{group}.{name}` and mapping their data +// keys to `/v1/{group}/{name}/{file}` URLs. This replaces the +// y-kustomize service in ystack. +type YKustomizeInClusterConfig struct { + // Namespace to watch. If empty: the pod's namespace when running + // in-cluster, else the kubeconfig current-context namespace, else + // "default". + Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"` + + // LabelSelector filters Secrets. Defaults to the ystack + // convention `yolean.se/module-part=y-kustomize`. + LabelSelector string `json:"labelSelector,omitempty" yaml:"labelSelector,omitempty"` + + // Kubeconfig is an optional explicit kubeconfig path. Empty uses + // the default loader (in-cluster, then KUBECONFIG, then + // ~/.kube/config). + Kubeconfig string `json:"kubeconfig,omitempty" yaml:"kubeconfig,omitempty"` + + // Context is an optional kubeconfig context to override the + // current-context. Empty uses the current-context. + Context string `json:"context,omitempty" yaml:"context,omitempty"` +} + // LoadConfigDir reads `{dir}/y-cluster-serve.yaml`, validates it, and // returns the parsed config with Dir set to the absolute of `dir`. func LoadConfigDir(dir string) (*Config, error) { @@ -162,6 +189,23 @@ func (c *Config) validate() error { if c.Static != nil { return fmt.Errorf("static config not allowed for type %s", c.Type) } + if c.InCluster != nil { + return fmt.Errorf("inCluster config not allowed for type %s", c.Type) + } + case TypeYKustomizeInCluster: + if len(c.Sources) != 0 { + return fmt.Errorf("sources not allowed for type %s", c.Type) + } + if c.Static != nil { + return fmt.Errorf("static config not allowed for type %s", c.Type) + } + // InCluster is optional -- nil means "take every default", + // which is what a pod mount of an almost-empty config should + // do. Normalize to an empty struct so backends see a non-nil + // pointer. + if c.InCluster == nil { + c.InCluster = &YKustomizeInClusterConfig{} + } case TypeStatic: if c.Static == nil { return fmt.Errorf("type %s requires static block", c.Type) @@ -172,6 +216,9 @@ func (c *Config) validate() error { if len(c.Sources) != 0 { return fmt.Errorf("sources not allowed for type %s", c.Type) } + if c.InCluster != nil { + return fmt.Errorf("inCluster config not allowed for type %s", c.Type) + } switch c.Static.DirTrailingSlash { case "", "redirect": default: diff --git a/pkg/serve/health.go b/pkg/serve/health.go index 55bd557..9037a3f 100644 --- a/pkg/serve/health.go +++ b/pkg/serve/health.go @@ -7,7 +7,21 @@ import ( // HealthHandler returns 200 with a small JSON payload describing the // backend. Ensure probes this on every configured port before returning. +// `extra` is snapshotted once; for backends whose payload changes over +// time (e.g. watch-driven route counts), use HealthHandlerFunc. func HealthHandler(kind BackendType, extra map[string]any) http.HandlerFunc { + snap := make(map[string]any, len(extra)) + for k, v := range extra { + snap[k] = v + } + return HealthHandlerFunc(kind, func() map[string]any { return snap }) +} + +// HealthHandlerFunc is like HealthHandler but invokes the provider on +// every request, so watch-driven backends can report the current +// number of routes/namespace/etc. The provider may be nil, in which +// case only {ok, type} is returned. +func HealthHandlerFunc(kind BackendType, extra func() map[string]any) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { MethodNotAllowed(w, http.MethodGet, http.MethodHead) @@ -17,8 +31,10 @@ func HealthHandler(kind BackendType, extra map[string]any) http.HandlerFunc { "ok": true, "type": string(kind), } - for k, v := range extra { - payload[k] = v + if extra != nil { + for k, v := range extra() { + payload[k] = v + } } body, _ := json.Marshal(payload) w.Header().Set("Content-Type", "application/json") diff --git a/pkg/serve/http.go b/pkg/serve/http.go index a6965eb..0089da2 100644 --- a/pkg/serve/http.go +++ b/pkg/serve/http.go @@ -44,15 +44,22 @@ func ComputeETag(body []byte) string { } // WriteAsset renders body with y-cluster-serve's standard headers: -// ETag, Cache-Control forcing revalidation, and the detected content type. -// Honors If-None-Match → 304 and supports HEAD by discarding the body -// while preserving Content-Length. +// ETag, Cache-Control forcing revalidation, and the content type +// detected from `filename`. Honors If-None-Match -> 304 and supports +// HEAD by discarding the body while preserving Content-Length. func WriteAsset(w http.ResponseWriter, r *http.Request, filename string, body []byte) { + WriteAssetAs(w, r, body, DetectContentType(filename)) +} + +// WriteAssetAs is the same as WriteAsset but takes an explicit +// content type, for callers that transform the body (e.g. yamlToJson) +// and cannot rely on filename-based detection. +func WriteAssetAs(w http.ResponseWriter, r *http.Request, body []byte, contentType string) { etag := ComputeETag(body) h := w.Header() h.Set("ETag", etag) h.Set("Cache-Control", "no-cache, must-revalidate") - h.Set("Content-Type", DetectContentType(filename)) + h.Set("Content-Type", contentType) h.Set("Content-Length", strconv.Itoa(len(body))) if matchesETag(r.Header.Get("If-None-Match"), etag) { diff --git a/pkg/serve/kubeclient.go b/pkg/serve/kubeclient.go new file mode 100644 index 0000000..4a90963 --- /dev/null +++ b/pkg/serve/kubeclient.go @@ -0,0 +1,78 @@ +package serve + +import ( + "fmt" + "os" + "strings" + + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// saNamespacePath is where a pod's assigned namespace lives when the +// process runs inside a Kubernetes cluster. Present only in-cluster, +// so stat-ing it is a quick in-cluster detector. +const saNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" + +// loadK8sConfig returns a REST config and resolved namespace for the +// in-cluster backend. Strategy: +// +// 1. If the caller pinned Kubeconfig or Context, build from those. +// 2. Otherwise, try rest.InClusterConfig(). +// 3. Fall back to clientcmd default loading rules (KUBECONFIG env, +// ~/.kube/config, etc.). +// +// Namespace resolution, first match wins: +// +// 1. cfg.Namespace (explicit). +// 2. /var/run/secrets/kubernetes.io/serviceaccount/namespace +// (present in-cluster). +// 3. kubeconfig current-context namespace. +// 4. "default". +func loadK8sConfig(cfg YKustomizeInClusterConfig) (*rest.Config, string, error) { + var restCfg *rest.Config + var kubeconfigNamespace string + + explicit := cfg.Kubeconfig != "" || cfg.Context != "" + if !explicit { + if c, err := rest.InClusterConfig(); err == nil { + restCfg = c + } + } + + if restCfg == nil { + rules := clientcmd.NewDefaultClientConfigLoadingRules() + if cfg.Kubeconfig != "" { + rules.ExplicitPath = cfg.Kubeconfig + } + overrides := &clientcmd.ConfigOverrides{} + if cfg.Context != "" { + overrides.CurrentContext = cfg.Context + } + kc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(rules, overrides) + c, err := kc.ClientConfig() + if err != nil { + return nil, "", fmt.Errorf("load kubeconfig: %w", err) + } + restCfg = c + // The current-context namespace becomes a namespace fallback. + if ns, _, err := kc.Namespace(); err == nil { + kubeconfigNamespace = ns + } + } + + ns := cfg.Namespace + if ns == "" { + if inCluster, err := os.ReadFile(saNamespacePath); err == nil { + ns = strings.TrimSpace(string(inCluster)) + } + } + if ns == "" { + ns = kubeconfigNamespace + } + if ns == "" { + ns = "default" + } + + return restCfg, ns, nil +} diff --git a/pkg/serve/openapi.go b/pkg/serve/openapi.go index 26e8d64..bdbb89c 100644 --- a/pkg/serve/openapi.go +++ b/pkg/serve/openapi.go @@ -66,14 +66,22 @@ func yamlEscape(s string) string { return `"` + esc + `"` } -// OpenAPIHandler serves a pre-rendered spec. The spec is snapshotted at -// backend construction per SERVE_FEATURE.md §"Scope limitation". +// OpenAPIHandler serves a pre-rendered spec. The spec is snapshotted +// at backend construction for backends where routes are fixed after +// start (static, y-kustomize-local). func OpenAPIHandler(spec []byte) http.HandlerFunc { + return OpenAPIHandlerFunc(func() []byte { return spec }) +} + +// OpenAPIHandlerFunc renders the spec on each request. Used by the +// in-cluster backend, where routes come from a live watch; the spec +// adapts to the watch per SERVE_FEATURE.md. +func OpenAPIHandlerFunc(render func() []byte) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { MethodNotAllowed(w, http.MethodGet, http.MethodHead) return } - WriteAsset(w, r, "openapi.yaml", spec) + WriteAsset(w, r, "openapi.yaml", render()) } } diff --git a/pkg/serve/process.go b/pkg/serve/process.go index 5ad9238..ba4ed64 100644 --- a/pkg/serve/process.go +++ b/pkg/serve/process.go @@ -30,29 +30,23 @@ type server struct { } // buildServers constructs the per-port handlers. Returns a startable -// slice and the list of health URLs Ensure probes after start. -func buildServers(cfgs []*Config, logger *zap.Logger) ([]*server, []string, error) { +// slice and the list of health URLs Ensure probes after start. ctx +// is the daemon lifetime: backends that spawn goroutines (in-cluster +// informers) tie them to this context so SIGTERM shuts them down. +func buildServers(ctx context.Context, cfgs []*Config, logger *zap.Logger) ([]*server, []string, error) { out := make([]*server, 0, len(cfgs)) healthURLs := make([]string, 0, len(cfgs)) for _, c := range cfgs { mux := http.NewServeMux() + title := fmt.Sprintf("y-cluster serve :%d", c.Port) switch c.Type { case TypeYKustomizeLocal: b, err := newYKustomizeLocalBackend(c) if err != nil { return nil, nil, fmt.Errorf("port %d: %w", c.Port, err) } - routes := make([]specRoute, 0, len(b.Routes())) - for _, p := range b.Routes() { - routes = append(routes, specRoute{Path: p, ContentType: b.RouteContentType(p)}) - } - spec := newOpenAPISpec( - fmt.Sprintf("y-cluster serve :%d", c.Port), - TypeYKustomizeLocal, - "dev", - routes, - ).Render() - + snap := buildYKRoutesSpec(b.Routes(), b.RouteContentType) + spec := newOpenAPISpec(title, TypeYKustomizeLocal, "dev", snap).Render() mux.Handle("/health", HealthHandler(TypeYKustomizeLocal, map[string]any{"routes": len(b.Routes())})) mux.Handle("/openapi.yaml", OpenAPIHandler(spec)) mux.Handle("/v1/", b) @@ -61,8 +55,35 @@ func buildServers(cfgs []*Config, logger *zap.Logger) ([]*server, []string, erro zap.String("type", string(c.Type)), zap.Int("routes", len(b.Routes())), ) + + case TypeYKustomizeInCluster: + b, err := newYKustomizeInClusterBackend(ctx, c, logger) + if err != nil { + return nil, nil, fmt.Errorf("port %d: %w", c.Port, err) + } + mux.Handle("/health", HealthHandlerFunc(TypeYKustomizeInCluster, b.Health)) + mux.Handle("/openapi.yaml", OpenAPIHandlerFunc(func() []byte { + snap := buildYKRoutesSpec(b.Routes(), b.RouteContentType) + return newOpenAPISpec(title, TypeYKustomizeInCluster, "dev", snap).Render() + })) + mux.Handle("/v1/", b) + case TypeStatic: - return nil, nil, fmt.Errorf("port %d: type %s is declared in the schema but not implemented in this release", c.Port, c.Type) + b, err := newStaticBackend(c, logger) + if err != nil { + return nil, nil, fmt.Errorf("port %d: %w", c.Port, err) + } + snap := b.specRoutes() + spec := newOpenAPISpec(title, TypeStatic, "dev", snap).Render() + mux.Handle("/health", HealthHandler(TypeStatic, map[string]any{"routes": len(snap)})) + mux.Handle("/openapi.yaml", OpenAPIHandler(spec)) + mux.Handle("/", b) + logger.Info("backend ready", + zap.Int("port", c.Port), + zap.String("type", string(c.Type)), + zap.Int("routes", len(snap)), + ) + default: return nil, nil, fmt.Errorf("port %d: unknown type %s", c.Port, c.Type) } @@ -81,6 +102,17 @@ func buildServers(cfgs []*Config, logger *zap.Logger) ([]*server, []string, erro return out, healthURLs, nil } +// buildYKRoutesSpec turns a backend's route list into the []specRoute +// shape the openapi renderer wants. Shared by the two y-kustomize +// backends and any future backend that exposes Routes()/RouteContentType(). +func buildYKRoutesSpec(paths []string, ct func(string) string) []specRoute { + out := make([]specRoute, 0, len(paths)) + for _, p := range paths { + out = append(out, specRoute{Path: p, ContentType: ct(p)}) + } + return out +} + // runDaemon blocks running every server until ctx is done or a server // exits with an unrecoverable error. Respects SIGTERM/SIGINT via the // ctx the caller passes in. diff --git a/pkg/serve/schema/y-cluster-serve.schema.json b/pkg/serve/schema/y-cluster-serve.schema.json index 68aa911..776d9e3 100644 --- a/pkg/serve/schema/y-cluster-serve.schema.json +++ b/pkg/serve/schema/y-cluster-serve.schema.json @@ -15,18 +15,18 @@ }, "type": { "type": "string", - "enum": ["static", "y-kustomize-local"], + "enum": ["static", "y-kustomize-local", "y-kustomize-in-cluster"], "description": "Backend selector." }, "static": { "type": "object", - "description": "Parameters for type=static. Not implemented in the first release.", + "description": "Parameters for type=static.", "required": ["dir"], "additionalProperties": false, "properties": { "dir": {"type": "string", "description": "Directory to serve."}, - "root": {"type": "string", "description": "Path prefix under which the dir appears in HTTP."}, - "yamlToJson": {"type": "boolean", "default": false, "description": "Transform application/yaml responses to application/json."}, + "root": {"type": "string", "description": "Path prefix under which the dir appears in HTTP. Empty means served at /."}, + "yamlToJson": {"type": "boolean", "default": false, "description": "Transform application/yaml responses to application/json. Off by default."}, "dirTrailingSlash": {"type": "string", "enum": ["", "redirect"], "description": "Behavior when a dir path is requested without trailing slash."} } }, @@ -42,6 +42,17 @@ "dir": {"type": "string"} } } + }, + "inCluster": { + "type": "object", + "description": "Parameters for type=y-kustomize-in-cluster. Watches Kubernetes Secrets named `y-kustomize.{group}.{name}` and serves their data keys at `/v1/{group}/{name}/{key}`.", + "additionalProperties": false, + "properties": { + "namespace": {"type": "string", "description": "Namespace to watch. Empty: pod namespace when in-cluster, else kubeconfig context namespace, else 'default'."}, + "labelSelector": {"type": "string", "description": "Label selector for Secret filtering. Default: yolean.se/module-part=y-kustomize."}, + "kubeconfig": {"type": "string", "description": "Optional explicit kubeconfig path."}, + "context": {"type": "string", "description": "Optional kubeconfig context override."} + } } }, "allOf": [ diff --git a/pkg/serve/serve.go b/pkg/serve/serve.go index 6c7bbb8..6332b4c 100644 --- a/pkg/serve/serve.go +++ b/pkg/serve/serve.go @@ -221,7 +221,7 @@ func runForeground(parent context.Context, cfgs []*Config, paths StatePaths) err defer func() { _ = logger.Sync() }() ctx, cancel := withSignals(parent) defer cancel() - servers, _, err := buildServers(cfgs, logger) + servers, _, err := buildServers(ctx, cfgs, logger) if err != nil { return err } @@ -261,7 +261,7 @@ func runAsDaemon(parent context.Context, cfgs []*Config, paths StatePaths) (retE ctx, cancel := withSignals(parent) defer cancel() - servers, _, err := buildServers(cfgs, logger) + servers, _, err := buildServers(ctx, cfgs, logger) if err != nil { logger.Error("build servers", zap.Error(err)) return err diff --git a/pkg/serve/serve_test.go b/pkg/serve/serve_test.go index 0791d84..8b742b1 100644 --- a/pkg/serve/serve_test.go +++ b/pkg/serve/serve_test.go @@ -350,19 +350,22 @@ func TestLogs_Follow(t *testing.T) { } } -func TestBuildServers_StaticNotImplemented(t *testing.T) { - c := &Config{Port: 1, Type: TypeStatic, Static: &StaticConfig{Dir: "/x"}, Dir: t.TempDir()} +func TestBuildServers_StaticMissingDir(t *testing.T) { + // A typo in static.dir used to be masked by the "not implemented" + // stub. Now that static is wired up, verify the error surfaces at + // buildServers time so the user notices during `serve ensure`. + c := &Config{Port: 1, Type: TypeStatic, Static: &StaticConfig{Dir: "/does/not/exist"}, Dir: t.TempDir()} logger := newConsoleLogger() - _, _, err := buildServers([]*Config{c}, logger) - if err == nil || !strings.Contains(err.Error(), "not implemented") { - t.Fatalf("want not-implemented, got %v", err) + _, _, err := buildServers(context.Background(), []*Config{c}, logger) + if err == nil { + t.Fatal("want error for missing static dir") } } func TestBuildServers_UnknownType(t *testing.T) { c := &Config{Port: 1, Type: BackendType("weird"), Dir: t.TempDir()} logger := newConsoleLogger() - _, _, err := buildServers([]*Config{c}, logger) + _, _, err := buildServers(context.Background(), []*Config{c}, logger) if err == nil || !strings.Contains(err.Error(), "unknown type") { t.Fatalf("want unknown-type, got %v", err) } diff --git a/pkg/serve/static.go b/pkg/serve/static.go index b56e4b9..0cc556d 100644 --- a/pkg/serve/static.go +++ b/pkg/serve/static.go @@ -1,7 +1,234 @@ package serve -// Static backend is declared in the config schema so configs written -// against the final shape round-trip, but the runtime is not -// implemented in the first release. See SERVE_FEATURE.md §"Initial -// scope" — y-kustomize-local is the only backend wired up. The schema -// stub lives here so future work replaces this single file. +import ( + "encoding/json" + "fmt" + "net/http" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "go.uber.org/zap" + "sigs.k8s.io/yaml" +) + +// staticBackend serves a directory tree under an optional `root` +// prefix. See SERVE_FEATURE.md §"usecase: Static assets". +type staticBackend struct { + cfg *Config + logger *zap.Logger + + absDir string // fully resolved filesystem root + root string // URL prefix, normalized to "/" or "/[/]*/" + routes []specRoute +} + +// newStaticBackend resolves the dir (relative to the config's own +// directory), verifies the directory exists, snapshots the file +// layout for the openapi spec, and returns a ready handler. +func newStaticBackend(cfg *Config, logger *zap.Logger) (*staticBackend, error) { + if cfg.Type != TypeStatic { + return nil, fmt.Errorf("not a static config: %s", cfg.Type) + } + sc := cfg.Static + if sc == nil { + return nil, fmt.Errorf("static block missing after validate") // defensive + } + + absDir := sc.Dir + if !filepath.IsAbs(absDir) { + absDir = filepath.Join(cfg.Dir, absDir) + } + absDir = filepath.Clean(absDir) + info, err := os.Stat(absDir) + if err != nil { + return nil, fmt.Errorf("static.dir %s: %w", sc.Dir, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("static.dir %s is not a directory", sc.Dir) + } + + root := normalizeRoot(sc.Root) + + b := &staticBackend{ + cfg: cfg, + logger: logger, + absDir: absDir, + root: root, + } + b.routes, err = b.scan() + if err != nil { + return nil, fmt.Errorf("scan %s: %w", absDir, err) + } + return b, nil +} + +// normalizeRoot returns "/" if the input is empty or "/", otherwise a +// string that begins and ends with "/" and has no internal "..". +func normalizeRoot(r string) string { + if r == "" || r == "/" { + return "/" + } + cleaned := path.Clean("/" + strings.TrimSuffix(r, "/")) + if cleaned == "/" { + return "/" + } + return cleaned + "/" +} + +// scan walks absDir and returns one specRoute per file, using the +// detected content type for each. Directories are skipped; symlinks +// are followed by default (Walk's default). Files under `absDir` +// that would land outside the root (only possible with very unusual +// symlinks) are silently skipped. +func (b *staticBackend) scan() ([]specRoute, error) { + var out []specRoute + err := filepath.Walk(b.absDir, func(p string, info os.FileInfo, werr error) error { + if werr != nil { + return werr + } + if info.IsDir() { + return nil + } + rel, err := filepath.Rel(b.absDir, p) + if err != nil { + return nil // defensive; Walk should never hand us a path outside absDir + } + urlPath := b.root + filepath.ToSlash(rel) + out = append(out, specRoute{ + Path: urlPath, + ContentType: b.contentTypeFor(rel), + }) + return nil + }) + if err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out, nil +} + +// contentTypeFor returns the detected content type for an asset, with +// the yamlToJson override applied if the file would be served as +// application/yaml. +func (b *staticBackend) contentTypeFor(rel string) string { + ct := DetectContentType(rel) + if b.cfg.Static.YAMLToJSON && ct == yamlMIME { + return "application/json" + } + return ct +} + +// specRoutes returns the openapi-ready snapshot. +func (b *staticBackend) specRoutes() []specRoute { + return b.routes +} + +// ServeHTTP implements http.Handler. +func (b *staticBackend) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + MethodNotAllowed(w, http.MethodGet, http.MethodHead) + return + } + + urlPath := r.URL.Path + // Gate everything by the configured root. + if !strings.HasPrefix(urlPath, b.root) { + http.NotFound(w, r) + return + } + + rel := strings.TrimPrefix(urlPath, b.root) + // Clean against path traversal; if the cleaned rel tries to + // escape, refuse. + if rel != "" { + cleaned := path.Clean("/" + rel) + if !strings.HasPrefix(cleaned, "/") { + http.NotFound(w, r) + return + } + rel = strings.TrimPrefix(cleaned, "/") + } + + fsPath := filepath.Join(b.absDir, filepath.FromSlash(rel)) + // Belt and braces: ensure the join stayed inside absDir. + absPath, err := filepath.Abs(fsPath) + if err != nil { + http.Error(w, "resolve: "+err.Error(), http.StatusInternalServerError) + return + } + if !strings.HasPrefix(absPath, b.absDir) { + http.NotFound(w, r) + return + } + + info, err := os.Stat(absPath) + if err != nil { + http.NotFound(w, r) + return + } + if info.IsDir() { + b.handleDir(w, r, urlPath) + return + } + + body, err := os.ReadFile(absPath) + if err != nil { + http.Error(w, "read: "+err.Error(), http.StatusInternalServerError) + return + } + + filename := filepath.Base(absPath) + if b.cfg.Static.YAMLToJSON && DetectContentType(filename) == yamlMIME { + jsonBody, err := yamlToMinifiedJSON(body) + if err != nil { + b.logger.Error("yamlToJson transform failed", + zap.String("path", urlPath), + zap.Error(err), + ) + http.Error(w, "yamlToJson: "+err.Error(), http.StatusInternalServerError) + return + } + WriteAssetAs(w, r, jsonBody, "application/json") + return + } + + WriteAsset(w, r, filename, body) +} + +// handleDir implements the dirTrailingSlash policy. A bare directory +// path always ends in 404 (no listing); the redirect mode adds a +// 302 hop when the trailing slash is missing. +func (b *staticBackend) handleDir(w http.ResponseWriter, r *http.Request, urlPath string) { + if b.cfg.Static.DirTrailingSlash == "redirect" && !strings.HasSuffix(urlPath, "/") { + target := urlPath + "/" + if r.URL.RawQuery != "" { + target += "?" + r.URL.RawQuery + } + // Fragments are client-side-only; HTTP servers never see them. + // Preserve via the browser's own redirect handling. + http.Redirect(w, r, target, http.StatusFound) + return + } + http.NotFound(w, r) +} + +// yamlToMinifiedJSON converts a YAML byte slice to minified JSON. +// sigs.k8s.io/yaml already round-trips via JSON internally, so the +// output is canonical JSON with no extra whitespace -- that matches +// the "minify the json" requirement in SERVE_FEATURE.md. +func yamlToMinifiedJSON(src []byte) ([]byte, error) { + j, err := yaml.YAMLToJSON(src) + if err != nil { + return nil, err + } + // YAMLToJSON emits compact JSON already, but re-marshalling + // normalizes whitespace in case upstream ever adds any. + var v any + if err := json.Unmarshal(j, &v); err != nil { + return nil, err + } + return json.Marshal(v) +} diff --git a/pkg/serve/static_test.go b/pkg/serve/static_test.go new file mode 100644 index 0000000..ede31c6 --- /dev/null +++ b/pkg/serve/static_test.go @@ -0,0 +1,393 @@ +package serve + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// staticCfg builds a Config for a static backend pointing at a freshly +// seeded directory. The helper lets each test tweak root/yamlToJson/ +// dirTrailingSlash without repeating the boilerplate. +func staticCfg(t *testing.T, sc StaticConfig, files map[string]string) *Config { + t.Helper() + cfgDir := t.TempDir() + dataDir := t.TempDir() + for rel, body := range files { + abs := filepath.Join(dataDir, rel) + if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(abs, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + sc.Dir = dataDir + return &Config{ + Dir: cfgDir, + Port: 1, + Type: TypeStatic, + Static: &sc, + } +} + +func staticServer(t *testing.T, cfg *Config) (*httptest.Server, *staticBackend) { + t.Helper() + b, err := newStaticBackend(cfg, newConsoleLogger()) + if err != nil { + t.Fatal(err) + } + return httptest.NewServer(b), b +} + +func TestStatic_Basic(t *testing.T) { + cfg := staticCfg(t, StaticConfig{Root: "/assets"}, map[string]string{ + "hello.yaml": "greeting: hi\n", + "nested/inner.json": `{"x":1}`, + }) + srv, b := staticServer(t, cfg) + defer srv.Close() + + // Expected openapi routes + want := []string{"/assets/hello.yaml", "/assets/nested/inner.json"} + got := b.specRoutes() + if len(got) != len(want) { + t.Fatalf("routes: %v", got) + } + for i, p := range want { + if got[i].Path != p { + t.Fatalf("route[%d]: got %s want %s", i, got[i].Path, p) + } + } + + // Serve the yaml file + resp, err := http.Get(srv.URL + "/assets/hello.yaml") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != "greeting: hi\n" { + t.Fatalf("body: %q", body) + } + if resp.Header.Get("Content-Type") != yamlMIME { + t.Fatalf("content-type: %s", resp.Header.Get("Content-Type")) + } + if resp.Header.Get("ETag") == "" { + t.Fatal("missing ETag") + } +} + +func TestStatic_NoRoot(t *testing.T) { + // Empty root means served under "/" + cfg := staticCfg(t, StaticConfig{}, map[string]string{ + "at-root.yaml": "k: v\n", + }) + srv, _ := staticServer(t, cfg) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/at-root.yaml") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("status: %d", resp.StatusCode) + } +} + +func TestStatic_NotFound(t *testing.T) { + cfg := staticCfg(t, StaticConfig{Root: "/a"}, map[string]string{ + "exists.yaml": "k: v\n", + }) + srv, _ := staticServer(t, cfg) + defer srv.Close() + + // Missing file under root + resp, _ := http.Get(srv.URL + "/a/missing.yaml") + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("missing: %d", resp.StatusCode) + } + + // Outside root + resp, _ = http.Get(srv.URL + "/other/path") + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("outside-root: %d", resp.StatusCode) + } +} + +func TestStatic_PathTraversal(t *testing.T) { + secret := t.TempDir() + if err := os.WriteFile(filepath.Join(secret, "secret.txt"), []byte("nope"), 0o644); err != nil { + t.Fatal(err) + } + cfg := staticCfg(t, StaticConfig{Root: "/a"}, map[string]string{ + "ok.yaml": "k: v\n", + }) + srv, _ := staticServer(t, cfg) + defer srv.Close() + + // Raw request with ../ escapes. net/http cleans URL.Path before + // dispatch, so this 404s via "outside root" -- but we also verify + // the belt-and-braces check works if someone constructs the path + // with filepath oddities. + resp, _ := http.Get(srv.URL + "/a/../../../../etc/passwd") + resp.Body.Close() + if resp.StatusCode == 200 { + t.Fatal("path traversal allowed") + } +} + +func TestStatic_DirWithoutTrailingSlash_Default(t *testing.T) { + cfg := staticCfg(t, StaticConfig{Root: "/a"}, map[string]string{ + "dir/file.yaml": "k: v\n", + }) + srv, _ := staticServer(t, cfg) + defer srv.Close() + + // Default: hitting a directory returns 404 (no listing). + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(srv.URL + "/a/dir") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("dir without slash: %d", resp.StatusCode) + } + // And with trailing slash + resp, err = client.Get(srv.URL + "/a/dir/") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("dir with slash: %d", resp.StatusCode) + } +} + +func TestStatic_DirTrailingSlashRedirect(t *testing.T) { + cfg := staticCfg(t, StaticConfig{Root: "/a", DirTrailingSlash: "redirect"}, map[string]string{ + "dir/file.yaml": "k: v\n", + }) + srv, _ := staticServer(t, cfg) + defer srv.Close() + + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get(srv.URL + "/a/dir?q=1") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusFound { + t.Fatalf("want 302, got %d", resp.StatusCode) + } + loc := resp.Header.Get("Location") + if loc != "/a/dir/?q=1" { + t.Fatalf("Location: %q (query string must be preserved)", loc) + } + + // With the trailing slash the target still 404s -- no listing. + resp, err = client.Get(srv.URL + "/a/dir/") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("target of redirect: %d", resp.StatusCode) + } +} + +func TestStatic_YAMLToJSON(t *testing.T) { + cfg := staticCfg(t, StaticConfig{Root: "/a", YAMLToJSON: true}, map[string]string{ + "doc.yaml": "greeting:\n text: hi\n count: 3\n", + "stay.json": `{"x":1}`, + "notyaml.txt": "plain\n", + }) + srv, b := staticServer(t, cfg) + defer srv.Close() + + // The openapi snapshot should advertise application/json for the + // yaml route since transformation is enabled. + for _, r := range b.specRoutes() { + if strings.HasSuffix(r.Path, ".yaml") && r.ContentType != "application/json" { + t.Fatalf("openapi still shows yaml ct: %+v", r) + } + } + + // GET transforms + resp, err := http.Get(srv.URL + "/a/doc.yaml") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.Header.Get("Content-Type") != "application/json" { + t.Fatalf("content-type: %s", resp.Header.Get("Content-Type")) + } + var parsed map[string]any + if err := json.Unmarshal(body, &parsed); err != nil { + t.Fatalf("not valid JSON: %v: %q", err, body) + } + // Minified: no extra spaces + if strings.Contains(string(body), " ") || strings.Contains(string(body), "\n") { + t.Fatalf("expected minified json, got %q", body) + } + if etag := resp.Header.Get("ETag"); etag == "" { + t.Fatal("missing ETag on transformed response") + } + // Content-Length matches JSON body length + cl := resp.Header.Get("Content-Length") + if cl != strconv.Itoa(len(body)) { + t.Fatalf("content-length %s vs body len %d", cl, len(body)) + } + + // HEAD produces same headers and no body + req, _ := http.NewRequest(http.MethodHead, srv.URL+"/a/doc.yaml", nil) + resp2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + headBody, _ := io.ReadAll(resp2.Body) + resp2.Body.Close() + if len(headBody) != 0 { + t.Fatalf("HEAD leaked body: %q", headBody) + } + if resp2.Header.Get("Content-Length") != cl { + t.Fatalf("HEAD content-length differs: %s vs %s", resp2.Header.Get("Content-Length"), cl) + } + if resp2.Header.Get("ETag") != resp.Header.Get("ETag") { + t.Fatalf("HEAD ETag differs from GET") + } + + // JSON file is served as-is (not double-transformed) + resp3, err := http.Get(srv.URL + "/a/stay.json") + if err != nil { + t.Fatal(err) + } + b3, _ := io.ReadAll(resp3.Body) + resp3.Body.Close() + if string(b3) != `{"x":1}` { + t.Fatalf("json passthrough: %q", b3) + } + + // Non-yaml file unaffected + resp4, err := http.Get(srv.URL + "/a/notyaml.txt") + if err != nil { + t.Fatal(err) + } + b4, _ := io.ReadAll(resp4.Body) + resp4.Body.Close() + if string(b4) != "plain\n" { + t.Fatalf("plain: %q", b4) + } +} + +func TestStatic_YAMLToJSON_InvalidYaml500(t *testing.T) { + cfg := staticCfg(t, StaticConfig{Root: "/a", YAMLToJSON: true}, map[string]string{ + "bad.yaml": "not: [valid\n yaml\n", + }) + srv, _ := staticServer(t, cfg) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/a/bad.yaml") + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != 500 { + t.Fatalf("want 500, got %d", resp.StatusCode) + } +} + +func TestStatic_ConditionalGET(t *testing.T) { + cfg := staticCfg(t, StaticConfig{Root: "/a"}, map[string]string{ + "x.yaml": "k: v\n", + }) + srv, _ := staticServer(t, cfg) + defer srv.Close() + + resp, _ := http.Get(srv.URL + "/a/x.yaml") + resp.Body.Close() + etag := resp.Header.Get("ETag") + + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/a/x.yaml", nil) + req.Header.Set("If-None-Match", etag) + resp2, _ := http.DefaultClient.Do(req) + resp2.Body.Close() + if resp2.StatusCode != http.StatusNotModified { + t.Fatalf("want 304, got %d", resp2.StatusCode) + } +} + +func TestStatic_MethodNotAllowed(t *testing.T) { + cfg := staticCfg(t, StaticConfig{Root: "/a"}, map[string]string{"x.yaml": "k: v\n"}) + srv, _ := staticServer(t, cfg) + defer srv.Close() + resp, _ := http.Post(srv.URL+"/a/x.yaml", "text/plain", strings.NewReader("")) + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST: %d", resp.StatusCode) + } +} + +func TestStatic_MissingDir(t *testing.T) { + cfg := &Config{ + Dir: t.TempDir(), + Port: 1, + Type: TypeStatic, + Static: &StaticConfig{Dir: "/this/really/should/not/exist"}, + } + if _, err := newStaticBackend(cfg, newConsoleLogger()); err == nil { + t.Fatal("want missing-dir error") + } +} + +func TestStatic_DirIsFile(t *testing.T) { + f := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + cfg := &Config{ + Dir: t.TempDir(), + Port: 1, + Type: TypeStatic, + Static: &StaticConfig{Dir: f}, + } + if _, err := newStaticBackend(cfg, newConsoleLogger()); err == nil { + t.Fatal("want not-a-directory error") + } +} + +func TestStatic_WrongType(t *testing.T) { + cfg := &Config{Port: 1, Type: TypeYKustomizeLocal, Dir: t.TempDir()} + if _, err := newStaticBackend(cfg, newConsoleLogger()); err == nil { + t.Fatal("want wrong-type error") + } +} + +func TestNormalizeRoot(t *testing.T) { + cases := []struct{ in, want string }{ + {"", "/"}, + {"/", "/"}, + {"/assets", "/assets/"}, + {"/assets/", "/assets/"}, + {"assets", "/assets/"}, + {"/a/b/c/", "/a/b/c/"}, + } + for _, c := range cases { + got := normalizeRoot(c.in) + if got != c.want { + t.Fatalf("normalizeRoot(%q) = %q, want %q", c.in, got, c.want) + } + } +} + diff --git a/pkg/serve/ykustomizeincluster.go b/pkg/serve/ykustomizeincluster.go new file mode 100644 index 0000000..534effb --- /dev/null +++ b/pkg/serve/ykustomizeincluster.go @@ -0,0 +1,271 @@ +package serve + +import ( + "context" + "fmt" + "net/http" + "sort" + "strings" + "sync" + "time" + + "go.uber.org/zap" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/informers" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/cache" +) + +const ( + // ykInClusterSecretPrefix is the naming convention inherited + // from ystack: Secret name `y-kustomize.{group}.{name}`. + ykInClusterSecretPrefix = "y-kustomize." + + // ykInClusterDefaultLabel is the Secret label selector ystack + // applies to every y-kustomize Secret. Configurable via + // inCluster.labelSelector. + ykInClusterDefaultLabel = "yolean.se/module-part=y-kustomize" + + // ykInClusterResyncPeriod controls how often the informer + // re-lists Secrets even without events. Ten minutes matches the + // k8s.io/client-go examples and is cheap on small object counts. + ykInClusterResyncPeriod = 10 * time.Minute +) + +// ykInClusterRoute is an in-memory served path. Unlike the local +// backend which reads from disk on every request, the in-cluster +// backend keeps bodies in memory because the authoritative source is +// the informer's store, which is itself in-memory. +type ykInClusterRoute struct { + Path string + ContentType string + Body []byte +} + +// ykInClusterBackend watches Kubernetes Secrets with a matching label +// and serves each Secret's data keys at `/v1/{group}/{name}/{key}`. +type ykInClusterBackend struct { + namespace string + selector string + logger *zap.Logger + + informer cache.SharedIndexInformer + factory informers.SharedInformerFactory + stopCh chan struct{} + + mu sync.RWMutex + routes map[string]ykInClusterRoute +} + +// clientFactory is an injection point so tests can substitute a +// fake.Clientset without touching kubeconfig loading. +type clientFactory func(cfg YKustomizeInClusterConfig) (kubernetes.Interface, string, error) + +var defaultClientFactory clientFactory = func(cfg YKustomizeInClusterConfig) (kubernetes.Interface, string, error) { + restCfg, ns, err := loadK8sConfig(cfg) + if err != nil { + return nil, "", fmt.Errorf("kube config: %w", err) + } + cs, err := kubernetes.NewForConfig(restCfg) + if err != nil { + return nil, "", fmt.Errorf("kube client: %w", err) + } + return cs, ns, nil +} + +// newYKustomizeInClusterBackend builds a backend, starts its informer, +// waits for initial cache sync, and populates the initial route table. +// The informer keeps running until ctx is cancelled or Close() is called. +func newYKustomizeInClusterBackend(ctx context.Context, cfg *Config, logger *zap.Logger) (*ykInClusterBackend, error) { + return newYKustomizeInClusterBackendWith(ctx, cfg, logger, defaultClientFactory) +} + +func newYKustomizeInClusterBackendWith(ctx context.Context, cfg *Config, logger *zap.Logger, cf clientFactory) (*ykInClusterBackend, error) { + if cfg.Type != TypeYKustomizeInCluster { + return nil, fmt.Errorf("not a y-kustomize-in-cluster config: %s", cfg.Type) + } + ic := cfg.InCluster + if ic == nil { + return nil, fmt.Errorf("inCluster config missing after validate") // defensive + } + + clientset, namespace, err := cf(*ic) + if err != nil { + return nil, err + } + + selector := ic.LabelSelector + if selector == "" { + selector = ykInClusterDefaultLabel + } + + b := &ykInClusterBackend{ + namespace: namespace, + selector: selector, + logger: logger, + stopCh: make(chan struct{}), + routes: make(map[string]ykInClusterRoute), + } + + factory := informers.NewSharedInformerFactoryWithOptions( + clientset, + ykInClusterResyncPeriod, + informers.WithNamespace(namespace), + informers.WithTweakListOptions(func(opts *metav1.ListOptions) { + opts.LabelSelector = selector + }), + ) + b.factory = factory + b.informer = factory.Core().V1().Secrets().Informer() + + if _, err := b.informer.AddEventHandler(cache.ResourceEventHandlerFuncs{ + AddFunc: func(obj any) { b.onChange() }, + UpdateFunc: func(_, obj any) { b.onChange() }, + DeleteFunc: func(obj any) { b.onChange() }, + }); err != nil { + return nil, fmt.Errorf("register handler: %w", err) + } + + // Plumb ctx cancellation through to the informer's stopCh so a + // SIGTERM to the daemon cleanly stops the watch goroutines. + go func() { + <-ctx.Done() + b.Close() + }() + + factory.Start(b.stopCh) + + if !cache.WaitForCacheSync(b.stopCh, b.informer.HasSynced) { + return nil, fmt.Errorf("initial cache sync cancelled") + } + b.rebuild() + + logger.Info("in-cluster backend ready", + zap.Int("port", cfg.Port), + zap.String("namespace", namespace), + zap.String("labelSelector", selector), + zap.Int("routes", len(b.routes)), + ) + return b, nil +} + +// Close stops the informer. Safe to call multiple times. +func (b *ykInClusterBackend) Close() { + select { + case <-b.stopCh: + // already closed + default: + close(b.stopCh) + } +} + +// onChange rebuilds the route table under write lock. The informer +// fires events in a single worker goroutine so we won't race with +// ourselves; readers take the read lock and see a coherent snapshot. +func (b *ykInClusterBackend) onChange() { + b.rebuild() +} + +func (b *ykInClusterBackend) rebuild() { + routes := make(map[string]ykInClusterRoute) + for _, obj := range b.informer.GetStore().List() { + sec, ok := obj.(*corev1.Secret) + if !ok { + continue + } + addSecretRoutes(sec, routes) + } + + b.mu.Lock() + prev := len(b.routes) + b.routes = routes + b.mu.Unlock() + + if prev != len(routes) { + b.logger.Info("in-cluster routes changed", + zap.Int("before", prev), + zap.Int("after", len(routes)), + ) + } +} + +// addSecretRoutes adds every data key of a matching Secret to the +// route map. Ignores Secrets whose name doesn't start with the +// `y-kustomize.` prefix (possible if a user applies the label to +// other Secrets). Preserves the `first dot only` behavior inherited +// from ystack's y-kustomize so renames behave identically there. +func addSecretRoutes(sec *corev1.Secret, routes map[string]ykInClusterRoute) { + if !strings.HasPrefix(sec.Name, ykInClusterSecretPrefix) { + return + } + suffix := strings.TrimPrefix(sec.Name, ykInClusterSecretPrefix) + pathBase := "/v1/" + strings.Replace(suffix, ".", "/", 1) + for key, val := range sec.Data { + route := pathBase + "/" + key + body := make([]byte, len(val)) + copy(body, val) + routes[route] = ykInClusterRoute{ + Path: route, + ContentType: DetectContentType(key), + Body: body, + } + } +} + +// ServeHTTP implements http.Handler. Only `/v1/**` paths are handled; +// the parent mux routes `/health` and `/openapi.yaml` separately. +func (b *ykInClusterBackend) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + MethodNotAllowed(w, http.MethodGet, http.MethodHead) + return + } + if !strings.HasPrefix(r.URL.Path, "/v1/") { + http.NotFound(w, r) + return + } + b.mu.RLock() + route, ok := b.routes[r.URL.Path] + b.mu.RUnlock() + if !ok { + http.NotFound(w, r) + return + } + WriteAsset(w, r, route.Path, route.Body) +} + +// Routes returns the sorted list of served paths (stable order). The +// openapi handler queries this on every request so the spec reflects +// the current watch state, per SERVE_FEATURE.md ("the openapi spec +// adapts to the watch"). +func (b *ykInClusterBackend) Routes() []string { + b.mu.RLock() + defer b.mu.RUnlock() + out := make([]string, 0, len(b.routes)) + for p := range b.routes { + out = append(out, p) + } + sort.Strings(out) + return out +} + +// RouteContentType returns the content type a route will be served +// with. Returns empty string for unknown routes. +func (b *ykInClusterBackend) RouteContentType(path string) string { + b.mu.RLock() + defer b.mu.RUnlock() + return b.routes[path].ContentType +} + +// Health returns a map of extra fields to include in /health alongside +// the standard ok/type fields. Computed on each call so it reflects +// the current watch state. +func (b *ykInClusterBackend) Health() map[string]any { + b.mu.RLock() + defer b.mu.RUnlock() + return map[string]any{ + "namespace": b.namespace, + "labelSelector": b.selector, + "routes": len(b.routes), + } +} diff --git a/pkg/serve/ykustomizeincluster_test.go b/pkg/serve/ykustomizeincluster_test.go new file mode 100644 index 0000000..afd313a --- /dev/null +++ b/pkg/serve/ykustomizeincluster_test.go @@ -0,0 +1,441 @@ +package serve + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" +) + +// newTestInClusterBackend builds a backend wired to a fake clientset. +// Caller controls lifetime via ctx (cancel to stop the informer). +// The backend's initial cache sync is waited on inside the +// constructor, so returning means /v1 lookups reflect `seed`. +func newTestInClusterBackend(t *testing.T, ctx context.Context, cfg *Config, seed ...*corev1.Secret) (*ykInClusterBackend, kubernetes.Interface) { + t.Helper() + objs := make([]runtime.Object, 0, len(seed)) + for _, s := range seed { + objs = append(objs, s) + } + cs := fake.NewClientset(objs...) + factory := func(ic YKustomizeInClusterConfig) (kubernetes.Interface, string, error) { + ns := ic.Namespace + if ns == "" { + ns = "default" + } + return cs, ns, nil + } + b, err := newYKustomizeInClusterBackendWith(ctx, cfg, newConsoleLogger(), factory) + if err != nil { + t.Fatal(err) + } + return b, cs +} + +// mkSecret builds a Secret with the y-kustomize label set so the +// default selector matches it. +func mkSecret(name string, data map[string]string) *corev1.Secret { + byteData := map[string][]byte{} + for k, v := range data { + byteData[k] = []byte(v) + } + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "default", + Labels: map[string]string{ + "yolean.se/module-part": "y-kustomize", + }, + }, + Data: byteData, + } +} + +// mkSecretBare builds a Secret without the default label, so the +// default selector filters it out. +func mkSecretBare(name string, data map[string]string) *corev1.Secret { + s := mkSecret(name, data) + s.Labels = nil + return s +} + +func cfgInCluster() *Config { + return &Config{ + Port: 1, + Type: TypeYKustomizeInCluster, + InCluster: &YKustomizeInClusterConfig{Namespace: "default"}, + } +} + +// waitForRouteCount polls the backend until it reports exactly `want` +// routes, or the deadline fires. Informer events are asynchronous so +// tests that mutate secrets after backend construction have to wait. +func waitForRouteCount(t *testing.T, b *ykInClusterBackend, want int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if len(b.Routes()) == want { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("route count: got %d, want %d (routes=%v)", len(b.Routes()), want, b.Routes()) +} + +func TestInCluster_InitialSync(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + b, _ := newTestInClusterBackend(t, ctx, cfg, + mkSecret("y-kustomize.blobs.setup-bucket-job", map[string]string{ + "base-for-annotations.yaml": "kind: Job\n", + }), + mkSecret("y-kustomize.kafka.setup-topic-job", map[string]string{ + "base-for-annotations.yaml": "kind: Job\n", + }), + ) + + got := b.Routes() + want := []string{ + "/v1/blobs/setup-bucket-job/base-for-annotations.yaml", + "/v1/kafka/setup-topic-job/base-for-annotations.yaml", + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("routes: got %v, want %v", got, want) + } +} + +func TestInCluster_SecretWithoutPrefixIgnored(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + b, _ := newTestInClusterBackend(t, ctx, cfg, + // Has the label but wrong name prefix. + mkSecret("unrelated-secret", map[string]string{"key": "value"}), + mkSecret("y-kustomize.blobs.setup-bucket-job", map[string]string{ + "base-for-annotations.yaml": "kind: Job\n", + }), + ) + if len(b.Routes()) != 1 { + t.Fatalf("routes: %v (unrelated secret leaked)", b.Routes()) + } +} + +func TestInCluster_LabelSelectorFilters(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + b, _ := newTestInClusterBackend(t, ctx, cfg, + // Would match the name pattern but lacks the label. + mkSecretBare("y-kustomize.blobs.setup-bucket-job", map[string]string{ + "base-for-annotations.yaml": "kind: Job\n", + }), + ) + if len(b.Routes()) != 0 { + t.Fatalf("unlabeled secret leaked into routes: %v", b.Routes()) + } +} + +func TestInCluster_Serve200AndCached304(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + b, _ := newTestInClusterBackend(t, ctx, cfg, + mkSecret("y-kustomize.blobs.setup-bucket-job", map[string]string{ + "values.yaml": "bucket: builds\n", + }), + ) + srv := httptest.NewServer(b) + defer srv.Close() + + resp, err := http.Get(srv.URL + "/v1/blobs/setup-bucket-job/values.yaml") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if string(body) != "bucket: builds\n" { + t.Fatalf("body: %q", body) + } + if resp.Header.Get("Content-Type") != yamlMIME { + t.Fatalf("content-type: %s", resp.Header.Get("Content-Type")) + } + etag := resp.Header.Get("ETag") + + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/v1/blobs/setup-bucket-job/values.yaml", nil) + req.Header.Set("If-None-Match", etag) + resp2, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp2.Body.Close() + if resp2.StatusCode != http.StatusNotModified { + t.Fatalf("conditional: %d", resp2.StatusCode) + } +} + +func TestInCluster_AddAfterStart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + b, cs := newTestInClusterBackend(t, ctx, cfg) // no seed + + if len(b.Routes()) != 0 { + t.Fatalf("routes before add: %v", b.Routes()) + } + + _, err := cs.CoreV1().Secrets("default").Create(ctx, + mkSecret("y-kustomize.blobs.setup-bucket-job", map[string]string{ + "base-for-annotations.yaml": "kind: Job\n", + }), + metav1.CreateOptions{}, + ) + if err != nil { + t.Fatal(err) + } + waitForRouteCount(t, b, 1) +} + +func TestInCluster_UpdateChangesBody(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + b, cs := newTestInClusterBackend(t, ctx, cfg, + mkSecret("y-kustomize.blobs.setup-bucket-job", map[string]string{ + "values.yaml": "bucket: builds\n", + }), + ) + srv := httptest.NewServer(b) + defer srv.Close() + + // Update the secret + s := mkSecret("y-kustomize.blobs.setup-bucket-job", map[string]string{ + "values.yaml": "bucket: builds-v2\n", + }) + _, err := cs.CoreV1().Secrets("default").Update(ctx, s, metav1.UpdateOptions{}) + if err != nil { + t.Fatal(err) + } + + // Poll until the served body changes + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + resp, err := http.Get(srv.URL + "/v1/blobs/setup-bucket-job/values.yaml") + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if strings.Contains(string(body), "builds-v2") { + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("update never propagated to served body") +} + +func TestInCluster_DeleteRemovesRoute(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + b, cs := newTestInClusterBackend(t, ctx, cfg, + mkSecret("y-kustomize.blobs.setup-bucket-job", map[string]string{ + "values.yaml": "x\n", + }), + ) + if len(b.Routes()) != 1 { + t.Fatalf("initial routes: %v", b.Routes()) + } + + if err := cs.CoreV1().Secrets("default").Delete(ctx, "y-kustomize.blobs.setup-bucket-job", metav1.DeleteOptions{}); err != nil { + t.Fatal(err) + } + waitForRouteCount(t, b, 0) +} + +func TestInCluster_Health(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + b, _ := newTestInClusterBackend(t, ctx, cfg, + mkSecret("y-kustomize.a.b", map[string]string{"k.yaml": "x"}), + ) + h := b.Health() + if h["namespace"] != "default" { + t.Fatalf("namespace: %v", h["namespace"]) + } + if h["routes"] != 1 { + t.Fatalf("routes count: %v", h["routes"]) + } +} + +func TestInCluster_OpenAPIReflectsWatch(t *testing.T) { + // End-to-end through buildServers-style wiring: an OpenAPIHandlerFunc + // should re-render the spec on each request so a secret added after + // start appears in the response. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + b, cs := newTestInClusterBackend(t, ctx, cfg) + + handler := OpenAPIHandlerFunc(func() []byte { + routes := buildYKRoutesSpec(b.Routes(), b.RouteContentType) + return newOpenAPISpec("test", TypeYKustomizeInCluster, "dev", routes).Render() + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + // Before any secret + resp, _ := http.Get(srv.URL) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if strings.Contains(string(body), "/v1/") { + t.Fatalf("empty spec should not list any /v1 path: %q", body) + } + + // Add a secret + _, err := cs.CoreV1().Secrets("default").Create(ctx, + mkSecret("y-kustomize.blobs.setup-bucket-job", map[string]string{ + "base-for-annotations.yaml": "kind: Job\n", + }), + metav1.CreateOptions{}, + ) + if err != nil { + t.Fatal(err) + } + waitForRouteCount(t, b, 1) + + resp, _ = http.Get(srv.URL) + body, _ = io.ReadAll(resp.Body) + resp.Body.Close() + if !strings.Contains(string(body), "/v1/blobs/setup-bucket-job/base-for-annotations.yaml") { + t.Fatalf("spec did not adapt to watch: %q", body) + } +} + +func TestInCluster_MethodNotAllowed(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := cfgInCluster() + b, _ := newTestInClusterBackend(t, ctx, cfg, + mkSecret("y-kustomize.a.b", map[string]string{"k.yaml": "v"}), + ) + srv := httptest.NewServer(b) + defer srv.Close() + resp, _ := http.Post(srv.URL+"/v1/a/b/k.yaml", "text/plain", strings.NewReader("")) + resp.Body.Close() + if resp.StatusCode != http.StatusMethodNotAllowed { + t.Fatalf("POST: %d", resp.StatusCode) + } +} + +func TestInCluster_NotFoundOutsideV1(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := cfgInCluster() + b, _ := newTestInClusterBackend(t, ctx, cfg, + mkSecret("y-kustomize.a.b", map[string]string{"k.yaml": "v"}), + ) + srv := httptest.NewServer(b) + defer srv.Close() + + resp, _ := http.Get(srv.URL + "/elsewhere") + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("outside /v1/: %d", resp.StatusCode) + } + + resp, _ = http.Get(srv.URL + "/v1/missing") + resp.Body.Close() + if resp.StatusCode != 404 { + t.Fatalf("unknown /v1 path: %d", resp.StatusCode) + } +} + +func TestInCluster_CloseIsIdempotent(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + cfg := cfgInCluster() + b, _ := newTestInClusterBackend(t, ctx, cfg) + b.Close() + b.Close() // must not panic +} + +func TestInCluster_WrongType(t *testing.T) { + cfg := &Config{Type: TypeStatic, InCluster: &YKustomizeInClusterConfig{}} + _, err := newYKustomizeInClusterBackendWith(context.Background(), cfg, newConsoleLogger(), + func(YKustomizeInClusterConfig) (kubernetes.Interface, string, error) { + return fake.NewClientset(), "x", nil + }) + if err == nil { + t.Fatal("want error for wrong type") + } +} + +func TestInCluster_CustomSelector(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + cfg := cfgInCluster() + cfg.InCluster.LabelSelector = "app=custom" + + // Secret with our custom label and y-kustomize name prefix. + match := mkSecret("y-kustomize.a.b", map[string]string{"k.yaml": "v"}) + match.Labels = map[string]string{"app": "custom"} + + // Secret with the y-kustomize default label but not our custom one. + other := mkSecret("y-kustomize.c.d", map[string]string{"k.yaml": "v"}) + + b, _ := newTestInClusterBackend(t, ctx, cfg, match, other) + if len(b.Routes()) != 1 { + t.Fatalf("custom selector: %v", b.Routes()) + } + if b.Routes()[0] != "/v1/a/b/k.yaml" { + t.Fatalf("wrong match: %v", b.Routes()) + } +} + +func TestInCluster_HealthHandlerDynamic(t *testing.T) { + // Verify the HealthHandlerFunc in health.go re-reads the + // provider on each request, which is what the in-cluster backend + // relies on. + calls := 0 + handler := HealthHandlerFunc(TypeYKustomizeInCluster, func() map[string]any { + calls++ + return map[string]any{"routes": calls} + }) + srv := httptest.NewServer(handler) + defer srv.Close() + + for i := 1; i <= 3; i++ { + resp, _ := http.Get(srv.URL) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + t.Fatal(err) + } + if payload["routes"].(float64) != float64(i) { + t.Fatalf("call %d reported routes=%v", i, payload["routes"]) + } + } +} diff --git a/testdata/serve-static/config/y-cluster-serve.yaml b/testdata/serve-static/config/y-cluster-serve.yaml new file mode 100644 index 0000000..e83e9e9 --- /dev/null +++ b/testdata/serve-static/config/y-cluster-serve.yaml @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=../../../pkg/serve/schema/y-cluster-serve.schema.json +port: __PORT__ +type: static +static: + dir: ../files + root: /assets + yamlToJson: true + dirTrailingSlash: redirect diff --git a/testdata/serve-static/files/README.txt b/testdata/serve-static/files/README.txt new file mode 100644 index 0000000..6316313 --- /dev/null +++ b/testdata/serve-static/files/README.txt @@ -0,0 +1,4 @@ +These files are served by y-cluster serve via the adjacent +y-cluster-serve.yaml config. The config enables yamlToJson so that +greetings/hello.yaml returns as application/json when fetched over +HTTP. diff --git a/testdata/serve-static/files/greetings/hello.yaml b/testdata/serve-static/files/greetings/hello.yaml new file mode 100644 index 0000000..69baf49 --- /dev/null +++ b/testdata/serve-static/files/greetings/hello.yaml @@ -0,0 +1,3 @@ +greeting: + text: hello + count: 3 diff --git a/testdata/serve-ykustomize-incluster/config/y-cluster-serve.yaml b/testdata/serve-ykustomize-incluster/config/y-cluster-serve.yaml new file mode 100644 index 0000000..f43f2fb --- /dev/null +++ b/testdata/serve-ykustomize-incluster/config/y-cluster-serve.yaml @@ -0,0 +1,13 @@ +# yaml-language-server: $schema=../../../pkg/serve/schema/y-cluster-serve.schema.json +# +# Config for the in-cluster e2e test. The test substitutes placeholders +# before running y-cluster serve. For a real pod deployment (see +# docs/ystack-migration.md) both placeholders can be omitted: the +# kubeconfig is inferred from the pod's service account, and namespace +# comes from the pod's namespace file. +port: __PORT__ +type: y-kustomize-in-cluster +inCluster: + kubeconfig: __KUBECONFIG__ + namespace: default + labelSelector: yolean.se/module-part=y-kustomize diff --git a/testdata/serve-ykustomize-incluster/secrets/blobs.yaml b/testdata/serve-ykustomize-incluster/secrets/blobs.yaml new file mode 100644 index 0000000..feed2a9 --- /dev/null +++ b/testdata/serve-ykustomize-incluster/secrets/blobs.yaml @@ -0,0 +1,26 @@ +apiVersion: v1 +kind: Secret +metadata: + name: y-kustomize.blobs.setup-bucket-job + namespace: default + labels: + yolean.se/module-part: y-kustomize +type: Opaque +stringData: + base-for-annotations.yaml: | + apiVersion: batch/v1 + kind: Job + metadata: + name: setup-bucket-job + annotations: + y-kustomize/base: blobs/setup-bucket-job + spec: + template: + spec: + restartPolicy: Never + containers: + - name: setup + image: busybox + command: ["true"] + values.yaml: | + bucket: builds