diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2c4b364..19aae85 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,19 +1,203 @@ 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + with: + version: v2.11.4 + args: --timeout=5m + 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 - - run: go test ./... + - run: go test -count=1 ./... + - run: go vet ./... + + # --- 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + cache: true + - 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@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + 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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + 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 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download linux/amd64 binary + 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@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: y-cluster-linux-arm64 + path: cmd/y-cluster/target/linux/arm64 + + - uses: solsson/setup-contain@49cc4cce1498df8a59e88fff52c1a9b747f11f08 # v1 + with: + version: v0.9.0 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + 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 new file mode 100644 index 0000000..29f7b27 --- /dev/null +++ b/.github/workflows/e2e-release.yaml @@ -0,0 +1,67 @@ +name: e2e Release + +# 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: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to test" + required: true + +jobs: + serve: + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + os_slug: linux + arch: amd64 + - os: macos-latest + os_slug: darwin + arch: arm64 + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - 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 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 "$asset" \ + --pattern "$sums" \ + --dir . + grep " $asset\$" "$sums" | sha256sum --check - + chmod +x "$asset" + mv "$asset" 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/.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/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..0933ce0 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,28 @@ +version: "2" +run: + timeout: 5m +linters: + default: none + enable: + - errcheck + - 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/.goreleaser.yaml b/.goreleaser.yaml deleted file mode 100644 index 1ad5cac..0000000 --- a/.goreleaser.yaml +++ /dev/null @@ -1,52 +0,0 @@ -version: 2 - -project_name: kustomize-traverse - -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 - -archives: - - id: default - name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}" - formats: [tar.gz] - files: - - LICENSE* - - README* - - SPEC* - -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: kustomize-traverse diff --git a/README.md b/README.md new file mode 100644 index 0000000..d890379 --- /dev/null +++ b/README.md @@ -0,0 +1,192 @@ +# y-cluster + +## 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 + +Idempotent Kubernetes convergence with dependency ordering and checks. + +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/ +``` + +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. + +### 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 + +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. + +### 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. + +The rule: + +- **CUE imports** are for ordering — they declare dependencies + between independently convergeable bases. Each dependency is + a separate yconverge invocation with its own checks. + +- **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. + +### 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 +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. + +## 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 + 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/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..3da97af --- /dev/null +++ b/cmd/y-cluster/contain.yaml @@ -0,0 +1,24 @@ +# 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 +platforms: +- linux/amd64 +- linux/arm64/v8 +layers: +- localFile: + 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 + gid: 65534 + mode: 0755 +entrypoint: +- /usr/local/bin/y-cluster diff --git a/cmd/y-cluster/main.go b/cmd/y-cluster/main.go new file mode 100644 index 0000000..0d61332 --- /dev/null +++ b/cmd/y-cluster/main.go @@ -0,0 +1,241 @@ +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/provision/qemu" + "github.com/Yolean/y-cluster/pkg/yconverge" +) + +var version = "dev" + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + 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) + } +} + +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()) + root.AddCommand(provisionCmd()) + root.AddCommand(teardownCmd()) + root.AddCommand(exportCmd()) + root.AddCommand(importCmd()) + root.AddCommand(serveCmd()) + + 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 + + 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() +} + +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/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/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/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/e2e/serve_test.go b/e2e/serve_test.go new file mode 100644 index 0000000..93559c6 --- /dev/null +++ b/e2e/serve_test.go @@ -0,0 +1,565 @@ +//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 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, name string, port int) string { + t.Helper() + src, err := filepath.Abs(filepath.Join("../testdata", name)) + 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, "serve-ykustomize-local", 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, "serve-ykustomize-local", 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 +} + +// 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/e2e/yconverge_test.go b/e2e/yconverge_test.go new file mode 100644 index 0000000..33aa98a --- /dev/null +++ b/e2e/yconverge_test.go @@ -0,0 +1,317 @@ +//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 ( + "context" + "fmt" + "os" + "os/exec" + "sync" + "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" +) + +func testdataDir(t *testing.T) string { + t.Helper() + abs, err := filepath.Abs("../testdata") + if err != nil { + t.Fatal(err) + } + return abs +} + +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() + + clusterOnce.Do(func() { + ctx := context.Background() + + if err := exec.CommandContext(ctx, "docker", "info").Run(); err != nil { + t.Skip("Docker not available") + } + + _ = exec.CommandContext(ctx, "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) + } + + 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: + 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(clusterKubeconfig, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + 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) + } + }) + + os.Setenv("KUBECONFIG", clusterKubeconfig) + t.Cleanup(func() { os.Unsetenv("KUBECONFIG") }) +} + +func logger(t *testing.T) *zap.Logger { + t.Helper() + l, err := zap.NewDevelopment() + if err != nil { + t.Fatal(err) + } + return l +} + +// --- 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-backend/base"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } + 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 TestOrdering_TransitiveChain(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + + // frontend → backend → db + result, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-frontend/base"), + }, logger(t)) + if err != nil { + t.Fatal(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 TestOrdering_PrintDepsNoCluster(t *testing.T) { + td := testdataDir(t) + + result, err := yconverge.Run(context.Background(), yconverge.Options{ + 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, got %v", basenames(result.Steps)) + } +} + +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) { + setupCluster(t) + td := testdataDir(t) + + // 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-db/qa"), + }, logger(t)) + if err != nil { + t.Fatal(err) + } +} + +func TestCustomization_BackendQaResolvesDbDependency(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + + // backend/qa wraps backend/base which depends on db + result, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-backend/qa"), + }, 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) + } +} + +// --- Idempotency --- + +func TestIdempotent_ReapplySucceeds(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + log := logger(t) + + 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) + } + } +} + +// --- ChecksOnly --- + +func TestChecksOnly_SkipsApply(t *testing.T) { + setupCluster(t) + td := testdataDir(t) + log := logger(t) + + _, err := yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-db/base"), + }, log) + if err != nil { + t.Fatal(err) + } + + _, err = yconverge.Run(context.Background(), yconverge.Options{ + Context: contextName, + KustomizeDir: filepath.Join(td, "e2e-db/base"), + ChecksOnly: true, + }, log) + 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 +} + +func indexOfDir(steps []string, segment string) int { + for i, s := range steps { + if strings.Contains(s, segment) { + return i + } + } + return -1 +} diff --git a/go.mod b/go.mod index 0e9d93d..d84dbbd 100644 --- a/go.mod +++ b/go.mod @@ -1,26 +1,67 @@ -module github.com/Yolean/kustomize-traverse +module github.com/Yolean/y-cluster 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 + 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 ( - github.com/davecgh/go-spew v1.1.1 // indirect + 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.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 - 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 - google.golang.org/protobuf v1.36.5 // 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.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 + 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 afb9ea6..8682802 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,25 @@ +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/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= @@ -12,14 +28,21 @@ 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/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/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.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= @@ -27,41 +50,115 @@ 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +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/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= 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= +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= -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= -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= +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.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= +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/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.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/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/kubeconfig/kubeconfig.go b/pkg/kubeconfig/kubeconfig.go new file mode 100644 index 0000000..1a3df84 --- /dev/null +++ b/pkg/kubeconfig/kubeconfig.go @@ -0,0 +1,138 @@ +// 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 { + // 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 new file mode 100644 index 0000000..ce32d00 --- /dev/null +++ b/pkg/kubeconfig/kubeconfig_test.go @@ -0,0 +1,229 @@ +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 +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + 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: [] +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + 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: {} +` + if err := os.WriteFile(path, []byte(existing), 0o600); err != nil { + t.Fatal(err) + } + + 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") + 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") + + m, _ := New("nonexistent", "nonexistent", nil) + // Should not panic or error when entries don't exist + m.CleanupStale() +} 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 +} diff --git a/pkg/provision/qemu/qemu.go b/pkg/provision/qemu/qemu.go new file mode 100644 index 0000000..0254e77 --- /dev/null +++ b/pkg/provision/qemu/qemu.go @@ -0,0 +1,436 @@ +// 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" + + "github.com/Yolean/y-cluster/pkg/kubeconfig" +) + +// 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 + Kubeconfig *kubeconfig.Manager +} + +// 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) { + // 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, + Kubeconfig: kubecfg, + } + + // 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() + } + + // Stop the VM process + 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) + } + 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 — 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)) + } 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" + +// 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 +} + +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..8983602 --- /dev/null +++ b/pkg/provision/qemu/qemu_test.go @@ -0,0 +1,109 @@ +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") + 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") + } +} + +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") + if err := os.WriteFile(diskPath, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + + 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") + if err := os.WriteFile(diskPath, []byte("fake"), 0o644); err != nil { + t.Fatal(err) + } + + 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") + } +} diff --git a/pkg/serve/config.go b/pkg/serve/config.go new file mode 100644 index 0000000..bc5bb81 --- /dev/null +++ b/pkg/serve/config.go @@ -0,0 +1,233 @@ +// 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" + TypeYKustomizeInCluster BackendType = "y-kustomize-in-cluster" + 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"` + InCluster *YKustomizeInClusterConfig `json:"inCluster,omitempty" yaml:"inCluster,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"` +} + +// 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) { + 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) + } + 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) + } + 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) + } + if c.InCluster != nil { + return fmt.Errorf("inCluster config 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..9037a3f --- /dev/null +++ b/pkg/serve/health.go @@ -0,0 +1,48 @@ +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. +// `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) + return + } + payload := map[string]any{ + "ok": true, + "type": string(kind), + } + if extra != nil { + 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..0089da2 --- /dev/null +++ b/pkg/serve/http.go @@ -0,0 +1,101 @@ +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 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", contentType) + 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/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 new file mode 100644 index 0000000..bdbb89c --- /dev/null +++ b/pkg/serve/openapi.go @@ -0,0 +1,87 @@ +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") + 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 { + fmt.Fprintf(&b, " %s:\n", yamlEscape(r.Path)) + b.WriteString(" get:\n") + b.WriteString(" responses:\n") + b.WriteString(" \"200\":\n") + b.WriteString(" content:\n") + fmt.Fprintf(&b, " %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 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", render()) + } +} 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..ba4ed64 --- /dev/null +++ b/pkg/serve/process.go @@ -0,0 +1,233 @@ +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. 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) + } + 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) + logger.Info("backend ready", + zap.Int("port", c.Port), + 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: + 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) + } + 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 +} + +// 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. +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..776d9e3 --- /dev/null +++ b/pkg/serve/schema/y-cluster-serve.schema.json @@ -0,0 +1,68 @@ +{ + "$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", "y-kustomize-in-cluster"], + "description": "Backend selector." + }, + "static": { + "type": "object", + "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. 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."} + } + }, + "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"} + } + } + }, + "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": [ + { + "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..6332b4c --- /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(ctx, 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(ctx, 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..8b742b1 --- /dev/null +++ b/pkg/serve/serve_test.go @@ -0,0 +1,394 @@ +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) + } + 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) + } + if !strings.Contains(buf.String(), "first") || !strings.Contains(buf.String(), "second") { + t.Fatalf("follow missed a line: %q", buf.String()) + } +} + +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(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(context.Background(), []*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..0cc556d --- /dev/null +++ b/pkg/serve/static.go @@ -0,0 +1,234 @@ +package serve + +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/pkg/serve/ykustomizelocal.go b/pkg/serve/ykustomizelocal.go new file mode 100644 index 0000000..4fc9479 --- /dev/null +++ b/pkg/serve/ykustomizelocal.go @@ -0,0 +1,203 @@ +package serve + +import ( + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Yolean/y-cluster/pkg/kustomize/traverse" +) + +// 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) + } + + 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) + } + 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 +} + +// 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 { + k, kpath, err := traverse.LoadKustomization(sourceDir) + if 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.FileSources { + if strings.Contains(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. +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..0d9b4ca --- /dev/null +++ b/pkg/serve/ykustomizelocal_test.go @@ -0,0 +1,375 @@ +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") + 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) + } +} + +func TestYK_BasesIsFile(t *testing.T) { + src := t.TempDir() + 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) + } +} + +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") + } +} + +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 +` + 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 { + 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 +` + 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 { + 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()) + } +} + +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 +` + 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") { + 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 + 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 { + 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 +` + 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") { + t.Fatalf("rename check should run for %s: %v", name, err) + } + }) + } +} 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..a1e29ed --- /dev/null +++ b/pkg/yconverge/cue_test.go @@ -0,0 +1,369 @@ +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) + } +} + +// 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 + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/pkg/yconverge/deps.go b/pkg/yconverge/deps.go new file mode 100644 index 0000000..52712d0 --- /dev/null +++ b/pkg/yconverge/deps.go @@ -0,0 +1,110 @@ +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 +} + +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 { + 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..2eacfee --- /dev/null +++ b/pkg/yconverge/yconverge.go @@ -0,0 +1,196 @@ +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 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 != "" { + // 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} + } + + 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", + "--force-conflicts", + "-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") + } +} 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" 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-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-backend/base/kustomization.yaml b/testdata/e2e-backend/base/kustomization.yaml new file mode 100644 index 0000000..8046de0 --- /dev/null +++ b/testdata/e2e-backend/base/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- 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..598ff59 --- /dev/null +++ b/testdata/e2e-backend/base/yconverge.cue @@ -0,0 +1,29 @@ +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" + }, + { + // 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-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-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-db/base/kustomization.yaml b/testdata/e2e-db/base/kustomization.yaml new file mode 100644 index 0000000..8035e66 --- /dev/null +++ b/testdata/e2e-db/base/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- 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..22731ea --- /dev/null +++ b/testdata/e2e-db/base/yconverge.cue @@ -0,0 +1,20 @@ +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" + }, + { + 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" + }, + ] +} diff --git a/testdata/e2e-db/qa/kustomization.yaml b/testdata/e2e-db/qa/kustomization.yaml new file mode 100644 index 0000000..206ed0c --- /dev/null +++ b/testdata/e2e-db/qa/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- ../base +labels: +- pairs: + env: qa + includeSelectors: false 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-frontend/base/kustomization.yaml b/testdata/e2e-frontend/base/kustomization.yaml new file mode 100644 index 0000000..5b300ed --- /dev/null +++ b/testdata/e2e-frontend/base/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: +- 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/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 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"]