From ea9193dae717a739c3c17510066cf5ddd45c4dab Mon Sep 17 00:00:00 2001 From: Oliver Baehler Date: Tue, 13 Oct 2026 13:37:11 +0200 Subject: [PATCH 1/2] fix: simplify task file Signed-off-by: Oliver Baehler --- .github/workflows/docs.yml | 4 +- DEVELOPMENT.md | 18 +- Taskfile.yml | 283 +++------- api/v1alpha1/changewindow_types.go | 28 +- ...dons.projectcapsule.dev_changewindows.yaml | 25 +- docs/content/docs/development/contributing.md | 21 +- docs/content/docs/guides/headlamp.md | 36 +- docs/content/docs/guides/operations.md | 46 +- docs/content/docs/reference/api.md | 6 +- docs/playwright.config.js | 1 + docs/tests/home.spec.js | 18 +- docs/tests/production.spec.js | 73 +++ e2e/after_hours_test.go | 5 +- e2e/status_test.go | 117 +++++ hack/dev/readiness.yaml | 23 + hack/tooling/check_tools_test.go | 70 --- hack/tooling/docs_test.go | 104 ++-- hack/tooling/image_test.go | 54 ++ hack/tooling/taskfile_test.go | 89 +++- hack/tooling/tooling_test.go | 5 +- headlamp/README.md | 42 +- headlamp/e2e/headlamp.spec.ts | 319 ++++++++++++ headlamp/src/Details.tsx | 87 +++- headlamp/src/NamespaceSelection.tsx | 94 ++++ headlamp/src/Overview.tsx | 153 +----- headlamp/src/WindowCalendar.tsx | 347 +++++++++++++ headlamp/src/WindowEvents.tsx | 73 +++ headlamp/src/calendar.test.ts | 122 +++++ headlamp/src/calendar.ts | 88 ++++ headlamp/src/hooks.test.ts | 66 +++ headlamp/src/hooks.ts | 80 ++- headlamp/src/matching.test.ts | 2 +- headlamp/src/matching.ts | 31 +- headlamp/src/schedule.bench.ts | 41 +- headlamp/src/schedule.test.ts | 2 +- headlamp/src/schedule.ts | 4 +- headlamp/src/testFixtures.ts | 15 +- headlamp/src/types.ts | 14 +- headlamp/src/views.test.tsx | 483 +++++++++++++++++- .../controller/changewindow_controller.go | 16 +- .../changewindow_controller_test.go | 8 + internal/controller/status.go | 78 +++ internal/controller/status_bench_test.go | 64 +++ internal/controller/status_test.go | 230 +++++++++ internal/controller/suite_test.go | 43 ++ 45 files changed, 2898 insertions(+), 630 deletions(-) create mode 100644 docs/tests/production.spec.js create mode 100644 e2e/status_test.go create mode 100644 hack/dev/readiness.yaml delete mode 100644 hack/tooling/check_tools_test.go create mode 100644 headlamp/src/NamespaceSelection.tsx create mode 100644 headlamp/src/WindowCalendar.tsx create mode 100644 headlamp/src/WindowEvents.tsx create mode 100644 headlamp/src/calendar.test.ts create mode 100644 headlamp/src/calendar.ts create mode 100644 headlamp/src/hooks.test.ts create mode 100644 internal/controller/status.go create mode 100644 internal/controller/status_bench_test.go create mode 100644 internal/controller/status_test.go diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 7aaa689..b4e6199 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -23,8 +23,8 @@ jobs: id: pages uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Check and build documentation - env: - DOCS_BASE_URL: ${{ steps.pages.outputs.base_url || 'https://change-windows.addons.projectcapsule.dev/' }} + # Use the canonical HTTPS URL from Taskfile.yml. Pages can report an HTTP + # base_url, which makes browsers block scripts resolved through Doks' base tag. run: devbox run -- task docs:check docs:build - name: Test desktop and mobile interactions run: devbox run -- task docs:browser:install docs:test:browser diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 455dd38..94d9177 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -120,8 +120,8 @@ devbox run -- task dev:deploy DEV_CONTEXT=kind-my-dev DEV_KIND_CLUSTER=my-dev Run these commands from the repository root. For repeated commands, enter `devbox shell` once and use `task dev:deploy` inside it. A bare `task` outside Devbox does not load the pinned tools: errors such as a missing `controller-gen` -mean the development environment is not active. The task checks its required -tools before generating code, building an image, or accessing the cluster. +mean the development environment is not active. Task uses the tools on PATH; +Devbox owns their versions, and command failures stop dependent steps. Kind is the default `DEV_IMAGE_MODE`. The task verifies that the selected context points to that Kind cluster, builds for its node architecture, and uses a fresh @@ -287,7 +287,9 @@ CI installs Chromium with its system dependencies and runs the same tests. Production output is in `dist/docs/`. The Documentation workflow checks the site and uploads a preview artifact for pull requests. Every push to `main` also deploys the checked site to GitHub Pages; it can be rerun manually on `main`. -Builds use the base URL returned by Pages, including a configured custom domain. +Builds use the canonical HTTPS URL from `Taskfile.yml`. Keep it and the custom +domain in `docs/hugo.toml` aligned; an HTTP base URL causes browsers to block +the navigation and search scripts as mixed content. In the repository settings, select **Pages → Build and deployment → Source → GitHub Actions** once. The workflow uses the built-in token and the `github-pages` @@ -321,8 +323,8 @@ GitHub Pages hosts the site; Netlify manages its DNS. The CNAME value is a hostname, without `https://` or a repository path. A `CNAME` file in the generated site is unnecessary for an Actions-based Pages -workflow. Hugo and Task default to the production URL, and CI uses the URL returned -by Configure Pages. Netlify does not need a build or hosting configuration. +workflow. Hugo, Task and CI use the HTTPS production URL. Netlify does not need a +build or hosting configuration. See [GitHub's custom domain setup](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site) and [Netlify DNS records](https://docs.netlify.com/manage/domains/configure-domains/dns-records/). @@ -332,9 +334,9 @@ and [Netlify DNS records](https://docs.netlify.com/manage/domains/configure-doma If `hugo version` reports an older Homebrew/system binary, use `devbox run -- task docs:serve`. Devbox requires Nix. Alternatively, install Hugo Extended 0.166.0 or newer and Node 24 and put them first on `PATH`. -Documentation tasks reject unsupported Hugo versions before generating or -building files. Use the Task commands to expose the local Sass compiler on -`PATH`; installing the theme does not replace your system Hugo. +Task delegates compatibility checks to Hugo and the theme. Use the Task commands +to expose the local Sass compiler on `PATH`; installing the theme does not replace +your system Hugo. The website's [development guide](docs/content/docs/development/contributing.md) explains architecture, testing, and the contribution/release process. diff --git a/Taskfile.yml b/Taskfile.yml index e2b4c16..979aa5b 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -47,18 +47,12 @@ tasks: generate: desc: Generate DeepCopy methods. run: once - preconditions: - - sh: command -v controller-gen - msg: Missing controller-gen. Run devbox run -- task or enter devbox shell; see DEVELOPMENT.md. cmds: - controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... manifests: desc: Generate CRDs and RBAC directly into the Helm chart. run: once - preconditions: - - sh: command -v controller-gen - msg: Missing controller-gen. Run devbox run -- task or enter devbox shell; see DEVELOPMENT.md. cmds: - controller-gen rbac:roleName=manager-role,fileName=manager-role.yaml crd paths=./... output:crd:dir=charts/change-windows/crds output:rbac:dir=charts/change-windows/files @@ -146,46 +140,11 @@ tasks: cmds: - npm ci --ignore-scripts --no-audit --no-fund - docs:toolchain: - desc: Check the required Hugo Extended version. - run: once - cmds: - - | - # Keep this aligned with docs/hugo.toml and the Devbox pin (checked by tooling tests). - minimum=0.166.0 - version='' - compatible=false - if version="$(hugo version 2>/dev/null)" && - [[ "$version" =~ ^hugo\ v([0-9]+)\.([0-9]+)\.([0-9]+)([^[:space:]]*) ]]; then - major="${BASH_REMATCH[1]}" - minor="${BASH_REMATCH[2]}" - patch="${BASH_REMATCH[3]}" - edition="${BASH_REMATCH[4]}" - IFS=. read -r min_major min_minor min_patch <<< "$minimum" - if [[ "$edition" =~ (^|\+)extended(\+|$) ]] && - (( major > min_major || - (major == min_major && minor > min_minor) || - (major == min_major && minor == min_minor && patch >= min_patch) )); then - compatible=true - fi - fi - - if [[ "$compatible" != true ]]; then - printf 'Documentation requires Hugo Extended >= %s.\n' "$minimum" >&2 - printf 'Found: %s\n' "${version:-hugo is missing or could not run}" >&2 - printf '%s\n' \ - 'Run from the repository root: devbox run -- task docs:serve' \ - 'Or put a compatible Hugo Extended installation on PATH.' \ - 'Older Hugo versions cannot load the theme and layouts/_shortcodes templates.' >&2 - exit 1 - fi - docs:build: desc: Build the Hugo documentation into dist/docs. env: DOCS_BASE_URL: '{{.DOCS_BASE_URL}}' cmds: - - task: docs:toolchain - task: docs:install - task: docs:generate - npm --prefix docs run build -- --baseURL "$DOCS_BASE_URL" @@ -193,7 +152,6 @@ tasks: docs:serve: desc: Preview documentation at http://localhost:1313 with live reload. cmds: - - task: docs:toolchain - task: docs:install - task: docs:generate - npm --prefix docs run dev -- --baseURL http://localhost:1313/ @@ -201,7 +159,6 @@ tasks: docs:check: desc: Check API-reference drift, example validity, and rendered links/assets. cmds: - - task: docs:toolchain - task: docs:install - | reference=$(mktemp) @@ -216,7 +173,6 @@ tasks: docs:test:browser: desc: Test the homepage YAML explorer on desktop and mobile Chromium. cmds: - - task: docs:toolchain - task: docs:install - npm --prefix docs run test:browser @@ -384,9 +340,6 @@ tasks: umask 077 : "${DEV_CONTEXT:?DEV_CONTEXT must name the development kubeconfig context}" - : "${DEV_NAMESPACE:?DEV_NAMESPACE is required}" - : "${DEV_RELEASE:?DEV_RELEASE is required}" - : "${DEV_CERT_DIR:?DEV_CERT_DIR is required}" # Validate before generating credentials or changing cluster resources. kubectl --context "$DEV_CONTEXT" cluster-info >/dev/null @@ -465,19 +418,9 @@ tasks: umask 077 : "${DEV_CONTEXT:?DEV_CONTEXT must name the development kubeconfig context}" - : "${DEV_NAMESPACE:?DEV_NAMESPACE is required}" - : "${DEV_RELEASE:?DEV_RELEASE is required}" - : "${DEV_CERT_DIR:?DEV_CERT_DIR is required}" - # Validate before generating credentials or changing cluster resources. - kubectl --context "$DEV_CONTEXT" cluster-info >/dev/null - mkdir -p "$DEV_CERT_DIR" - chmod 700 "$DEV_CERT_DIR" + [[ -s "$DEV_CERT_DIR/tls.crt" && -s "$DEV_CERT_DIR/tls.key" ]] || { echo 'Run task dev:setup first to create the serving certificates.' >&2; exit 1; } DEV_CERT_DIR=$(cd "$DEV_CERT_DIR" && pwd) - cert="$DEV_CERT_DIR/tls.crt" - key="$DEV_CERT_DIR/tls.key" - - [[ -s "$cert" && -s "$key" ]] || { echo 'Run task dev:setup first to create the serving certificates.' >&2; exit 1; } # Snapshot only the explicitly selected context, without mutating the user's config. run_dir=$(mktemp -d "$DEV_CERT_DIR/run.XXXXXX") controller_pid='' @@ -518,9 +461,10 @@ tasks: IMAGE_PLATFORM: '{{.IMAGE_PLATFORM}}' PLATFORMS: '{{.PLATFORMS}}' IMAGE_ARCHIVE: '{{.IMAGE_ARCHIVE}}' - preconditions: - - sh: for tool in go controller-gen ko helm kubectl openssl{{if eq .DEV_IMAGE_MODE "kind"}} kind{{end}}; do command -v "$tool" >/dev/null || exit 1; done - msg: Missing development tools. Run devbox run -- task or enter devbox shell; see DEVELOPMENT.md. + requires: + vars: + - name: DEV_IMAGE_MODE + enum: [kind, registry] cmds: - task: generate - task: manifests @@ -529,16 +473,9 @@ tasks: umask 077 : "${DEV_CONTEXT:?DEV_CONTEXT must name the development kubeconfig context}" - : "${DEV_NAMESPACE:?DEV_NAMESPACE is required}" - : "${DEV_RELEASE:?DEV_RELEASE is required}" - : "${IMG:?IMG must be a tagged image reference}" - : "${DEV_IMAGE_MODE:=kind}" - : "${DEV_TIMEOUT:=180}" - case "$DEV_IMAGE_MODE" in - kind) : "${DEV_KIND_CLUSTER:?DEV_KIND_CLUSTER must name an existing Kind cluster}" ;; - registry) ;; - *) echo 'DEV_IMAGE_MODE must be kind or registry' >&2; exit 1 ;; - esac + if [[ "$DEV_IMAGE_MODE" == kind ]]; then + : "${DEV_KIND_CLUSTER:?DEV_KIND_CLUSTER must name an existing Kind cluster}" + fi [[ "$DEV_TIMEOUT" =~ ^[1-9][0-9]*$ ]] || { echo 'DEV_TIMEOUT must be a positive number of seconds' >&2; exit 1; } values=(--reset-values) if [[ -n "${DEV_HELM_VALUES:-}" ]]; then @@ -553,12 +490,7 @@ tasks: } temp=$(mktemp -d) - cleanup() { - result=$? - rm -rf "$temp" - exit "$result" - } - trap cleanup EXIT + trap 'rm -rf "$temp"' EXIT pull_policy=IfNotPresent if [[ "$DEV_IMAGE_MODE" == kind ]]; then # Verify the selected context actually connects to the cluster receiving the image. @@ -585,10 +517,6 @@ tasks: values+=(--set manager.hostUsers=true) else image=$(task image:push) - if [[ ! "$image" =~ ^[^[:space:]@]+@sha256:[a-f0-9]{64}$ || "${image%@*}" != "${IMG%:*}" ]]; then - echo 'ko did not produce the expected immutable image reference' >&2 - exit 1 - fi fi # Inline CRDs avoid a separate hook image and support upgrades from dev:setup. @@ -610,33 +538,8 @@ tasks: --for="jsonpath={.webhooks[$index].clientConfig.caBundle}" --timeout="${DEV_TIMEOUT}s" done # Probe through the API server to check TLS and admission registration, with no persisted object. - cat > "$temp/probe.yaml" <<'YAML' - apiVersion: addons.projectcapsule.dev/v1alpha1 - kind: ChangeWindow - metadata: - generateName: dev-readiness- - spec: - namespaceSelectors: - - matchLabels: - change-windows.dev/readiness: probe - schedules: - - name: probe - cron: '0 0 * * *' - duration: 1m - targets: - - name: configs - apiVersion: v1 - kind: ConfigMap - admission: - - name: probe - targets: [configs] - operations: [UPDATE] - when: Active - action: Deny - message: Readiness probe - YAML deadline=$((SECONDS + DEV_TIMEOUT)) - until "${kube[@]}" create --dry-run=server -f "$temp/probe.yaml" > "$temp/probe.log" 2>&1; do + until "${kube[@]}" create --dry-run=server -f hack/dev/readiness.yaml > "$temp/probe.log" 2>&1; do if (( SECONDS >= deadline )); then cat "$temp/probe.log" >&2; exit 1; fi sleep 1 done @@ -646,70 +549,46 @@ tasks: image:build: desc: Build a Linux image archive with ko, without a container daemon or registry push. aliases: ['image:ko', ko-build-controller, ko-build-all] - env: + vars: &image-vars + IMAGE_TAG: '{{.IMG | splitList ":" | last}}' + IMAGE_REPOSITORY: '{{trimSuffix (printf ":%s" .IMAGE_TAG) .IMG}}' + env: &image-env IMG: '{{.IMG}}' + IMAGE_TAG: '{{.IMAGE_TAG}}' + KO_DOCKER_REPO: '{{.IMAGE_REPOSITORY}}' + CGO_ENABLED: '0' IMAGE_PLATFORM: '{{.IMAGE_PLATFORM}}' IMAGE_ARCHIVE: '{{.IMAGE_ARCHIVE}}' + PLATFORMS: '{{.PLATFORMS}}' + preconditions: &tagged-image + - sh: '[[ "$IMG" != *@* && "${IMG##*/}" == *:* && "$IMAGE_TAG" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ && "$KO_DOCKER_REPO" =~ ^[a-z0-9][a-z0-9./:_-]*$ ]]' + msg: IMG must contain a repository and one valid tag, without a digest. cmds: - task: generate - | - : "${IMG:?IMG must be a tagged image reference}" - # Split the tag from the last path component, retaining registry ports. - if [[ "$IMG" == *@* || "${IMG##*/}" != *:* || "$IMG" == *[[:space:]]* ]]; then - echo 'IMG must include a tag and must not contain a digest or whitespace' >&2 - exit 1 - fi - image_repo=${IMG%:*} - image_tag=${IMG##*:} - if [[ ! "$image_tag" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ || ! "$image_repo" =~ ^[a-z0-9][a-z0-9./:_-]*$ ]]; then - echo 'IMG contains an invalid repository or tag' >&2 - exit 1 - fi - export KO_DOCKER_REPO="$image_repo" - export CGO_ENABLED=0 - args=(build ./cmd --bare "--tags=$image_tag") : "${IMAGE_PLATFORM:=linux/$(go env GOHOSTARCH)}" - if [[ "$IMAGE_PLATFORM" != linux/* || "$IMAGE_PLATFORM" == *,* ]]; then - echo 'IMAGE_PLATFORM must select one Linux architecture for the local archive' >&2 - exit 1 - fi + [[ "$IMAGE_PLATFORM" == linux/* && "$IMAGE_PLATFORM" != *,* ]] || { echo 'IMAGE_PLATFORM must select one Linux architecture' >&2; exit 1; } mkdir -p "$(dirname "$IMAGE_ARCHIVE")" rm -f "$IMAGE_ARCHIVE" - ko "${args[@]}" --push=false "--platform=$IMAGE_PLATFORM" "--tarball=$IMAGE_ARCHIVE" --tag-only + ko build ./cmd --bare "--tags=$IMAGE_TAG" --push=false "--platform=$IMAGE_PLATFORM" "--tarball=$IMAGE_ARCHIVE" --tag-only image:push: desc: Publish multi-platform images with ko and print the immutable index reference. aliases: [ko-publish-controller] - env: - IMG: '{{.IMG}}' - PLATFORMS: '{{.PLATFORMS}}' + vars: *image-vars + env: *image-env + preconditions: *tagged-image cmds: - task generate >&2 - | - : "${IMG:?IMG must be a tagged image reference}" - # Split the tag from the last path component, retaining registry ports. - if [[ "$IMG" == *@* || "${IMG##*/}" != *:* || "$IMG" == *[[:space:]]* ]]; then - echo 'IMG must include a tag and must not contain a digest or whitespace' >&2 - exit 1 - fi - image_repo=${IMG%:*} - image_tag=${IMG##*:} - if [[ ! "$image_tag" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$ || ! "$image_repo" =~ ^[a-z0-9][a-z0-9./:_-]*$ ]]; then - echo 'IMG contains an invalid repository or tag' >&2 - exit 1 - fi - export KO_DOCKER_REPO="$image_repo" - export CGO_ENABLED=0 - args=(build ./cmd --bare "--tags=$image_tag") - # --image-refs includes platform manifests as well as the index. stdout - # identifies the published index; keep only that immutable reference. - ref=$(ko "${args[@]}" --push=true "--platform=$PLATFORMS") + # ko's stdout is the index; --image-refs also includes child manifests. + ref=$(ko build ./cmd --bare "--tags=$IMAGE_TAG" --push=true "--platform=$PLATFORMS") if [[ ! "$ref" =~ ^[^[:space:]@]+@sha256:[a-f0-9]{64}$ ]] || - [[ "${ref%@*}" != "$image_repo" && "${ref%@*}" != "$IMG" ]]; then + [[ "${ref%@*}" != "$KO_DOCKER_REPO" && "${ref%@*}" != "$IMG" ]]; then echo 'ko did not produce the expected immutable image reference' >&2 exit 1 fi - printf '%s@%s\n' "$image_repo" "${ref##*@}" + printf '%s@%s\n' "$KO_DOCKER_REPO" "${ref##*@}" image:sign: desc: Sign an immutable IMAGE_REF returned by image:push or helm:push. @@ -832,8 +711,6 @@ tasks: desc: Build and deploy through dev:deploy in isolated Kind, then run e2e tests. aliases: [test-e2e] env: - KIND: kind - KUBECTL: kubectl KIND_CLUSTER: '{{.KIND_CLUSTER}}' KIND_NODE_IMAGE: '{{.KIND_NODE_IMAGE}}' IMG: '{{.IMG}}' @@ -842,17 +719,12 @@ tasks: cmds: - | bash -euo pipefail <<'BASH' - : "${KIND:?KIND is required}" - : "${KUBECTL:?KUBECTL is required}" - : "${KIND_CLUSTER:?KIND_CLUSTER is required}" - : "${KIND_NODE_IMAGE:?KIND_NODE_IMAGE is required}" - : "${IMG:?IMG is required}" - : "${IMAGE_ARCHIVE:?IMAGE_ARCHIVE is required}" - : "${ARTIFACTS:?ARTIFACTS is required}" + umask 077 mkdir -p "$ARTIFACTS" : > "$ARTIFACTS/e2e.log" printf 'image=%s\nnode=%s\ncluster=%s\n' "$IMG" "$KIND_NODE_IMAGE" "$KIND_CLUSTER" > "$ARTIFACTS/run.txt" - if "$KIND" get clusters | grep -Fxq "$KIND_CLUSTER"; then + clusters=$(kind get clusters) + if grep -Fxq "$KIND_CLUSTER" <<< "$clusters"; then echo "Refusing to reuse or delete existing cluster $KIND_CLUSTER" >&2 exit 1 fi @@ -862,23 +734,26 @@ tasks: cluster_started=false cleanup() { result=$? + trap - EXIT INT TERM if [[ "$cluster_started" == true ]]; then - "$KIND" export logs --name "$KIND_CLUSTER" "$ARTIFACTS/cluster" > "$ARTIFACTS/export.log" 2>&1 || true - "$KUBECTL" get events -A > "$ARTIFACTS/events.log" 2>&1 || true - "$KUBECTL" get changewindows -o yaml > "$ARTIFACTS/changewindows.yaml" 2>&1 || true - "$KUBECTL" logs -n schedule-action-controller-system deployment/change-windows > "$ARTIFACTS/controller.log" 2>&1 || true - "$KIND" delete cluster --name "$KIND_CLUSTER" || result=1 + kind export logs --name "$KIND_CLUSTER" "$ARTIFACTS/cluster" > "$ARTIFACTS/export.log" 2>&1 || true + kubectl get events -A > "$ARTIFACTS/events.log" 2>&1 || true + kubectl get changewindows -o yaml > "$ARTIFACTS/changewindows.yaml" 2>&1 || true + kubectl logs -n schedule-action-controller-system deployment/change-windows > "$ARTIFACTS/controller.log" 2>&1 || true + kind delete cluster --name "$KIND_CLUSTER" || result=1 fi rm -f "$KUBECONFIG" exit "$result" } trap cleanup EXIT + trap 'exit 130' INT + trap 'exit 143' TERM cluster_started=true - "$KIND" create cluster --name "$KIND_CLUSTER" --image "$KIND_NODE_IMAGE" --kubeconfig "$KUBECONFIG" --wait 120s - "$KUBECTL" apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.2/cert-manager.yaml - "$KUBECTL" rollout status -n cert-manager deployment/cert-manager --timeout=120s - "$KUBECTL" rollout status -n cert-manager deployment/cert-manager-webhook --timeout=120s - "$KUBECTL" rollout status -n cert-manager deployment/cert-manager-cainjector --timeout=120s + kind create cluster --name "$KIND_CLUSTER" --image "$KIND_NODE_IMAGE" --kubeconfig "$KUBECONFIG" --wait 120s + kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.2/cert-manager.yaml + for deployment in cert-manager cert-manager-webhook cert-manager-cainjector; do + kubectl -n cert-manager rollout status "deployment/$deployment" --timeout=120s + done export DEV_CONTEXT="kind-$KIND_CLUSTER" export DEV_NAMESPACE=schedule-action-controller-system export DEV_RELEASE=change-windows @@ -887,9 +762,9 @@ tasks: export DEV_HELM_VALUES=charts/change-windows/ci/admission-values.yaml task dev:deploy # A second deployment must also converge without leaving an old image running. - previous_image=$("$KUBECTL" -n "$DEV_NAMESPACE" get deployment change-windows -o jsonpath='{.spec.template.spec.containers[0].image}') + previous_image=$(kubectl -n "$DEV_NAMESPACE" get deployment change-windows -o jsonpath='{.spec.template.spec.containers[0].image}') task dev:deploy - current_image=$("$KUBECTL" -n "$DEV_NAMESPACE" get deployment change-windows -o jsonpath='{.spec.template.spec.containers[0].image}') + current_image=$(kubectl -n "$DEV_NAMESPACE" get deployment change-windows -o jsonpath='{.spec.template.spec.containers[0].image}') [[ "$current_image" != "$previous_image" ]] || { echo 'Repeated dev deployment did not roll the image' >&2; exit 1; } printf 'first-image=%s\nsecond-image=%s\n' "$previous_image" "$current_image" >> "$ARTIFACTS/run.txt" # The e2e build tag keeps cluster-dependent tests out of ordinary go test runs. @@ -926,30 +801,21 @@ tasks: helm:package: desc: Package the chart using VERSION. env: - VERSION: '{{.VERSION}}' + CHART_VERSION: '{{trimPrefix "v" .VERSION}}' cmds: - task: manifests - - | - : "${VERSION:?VERSION is required}" - version=${VERSION#v} - if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then - echo 'VERSION must be SemVer without build metadata' >&2 - exit 1 - fi - mkdir -p dist/charts - helm package charts/change-windows --version "$version" --app-version "$version" --destination dist/charts + - mkdir -p dist/charts + - helm package charts/change-windows --version "$CHART_VERSION" --app-version "$CHART_VERSION" --destination dist/charts helm:push: desc: Publish the chart to CHART_REGISTRY and print its immutable reference. env: - VERSION: '{{.VERSION}}' + CHART_VERSION: '{{trimPrefix "v" .VERSION}}' + CHART_REGISTRY: '{{.CHART_REGISTRY | default "oci://ghcr.io/projectcapsule/charts"}}' cmds: - task helm:package >&2 - | - : "${VERSION:?VERSION is required}" - : "${CHART_REGISTRY:=oci://ghcr.io/projectcapsule/charts}" - version=${VERSION#v} - if ! output=$(helm push "dist/charts/change-windows-$version.tgz" "$CHART_REGISTRY" 2>&1); then + if ! output=$(helm push "dist/charts/change-windows-$CHART_VERSION.tgz" "$CHART_REGISTRY" 2>&1); then printf '%s\n' "$output" >&2 exit 1 fi @@ -981,42 +847,33 @@ tasks: enterprise:release: desc: Publish the enterprise image and chart (use DRY_RUN=true to preview). aliases: [enterprise-release] + vars: + CHANNEL: '{{.CHANNEL | default "enterprise"}}' + CHART_PATH: '{{if eq .CHANNEL "prereleases"}}charts/prereleases{{else}}charts{{end}}' + requires: + vars: + - name: CHANNEL + enum: [enterprise, prereleases] + - name: DRY_RUN + enum: ['true', 'false'] env: ENTERPRISE_VERSION: '{{.ENTERPRISE_VERSION}}' - ENTERPRISE_REGISTRY: '{{.ENTERPRISE_REGISTRY}}' DRY_RUN: '{{.DRY_RUN}}' - CHANNEL: '{{.CHANNEL | default "enterprise"}}' + IMAGE_REPOSITORY: '{{.ENTERPRISE_REGISTRY}}/{{.CHANNEL}}/change-windows' + CHART_REGISTRY: 'oci://{{.ENTERPRISE_REGISTRY}}/{{.CHART_PATH}}' cmds: - | bash -euo pipefail <<'BASH' - : "${ENTERPRISE_VERSION:?Set ENTERPRISE_VERSION to a SemVer version without a v prefix}" - : "${ENTERPRISE_REGISTRY:=registry.projectcapsule.dev}" - : "${CHANNEL:=enterprise}" - : "${DRY_RUN:=false}" - if [[ ! "$ENTERPRISE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then - echo 'ENTERPRISE_VERSION must be SemVer without a v prefix' >&2 - exit 1 - fi - # OCI image tags cannot contain SemVer build metadata. - if [[ "$ENTERPRISE_VERSION" == *+* ]]; then - echo 'Build metadata is not supported in image tags; use a prerelease suffix' >&2 - exit 1 - fi - case "$CHANNEL" in - enterprise) chart_path=charts ;; - prereleases) chart_path=charts/prereleases ;; - *) echo 'Unknown release channel' >&2; exit 1 ;; - esac - image="$ENTERPRISE_REGISTRY/$CHANNEL/change-windows" - chart="oci://$ENTERPRISE_REGISTRY/$chart_path" + : "${ENTERPRISE_VERSION:?Set ENTERPRISE_VERSION to the release version}" run() { if [[ "$DRY_RUN" == true ]]; then printf '%q ' "$@"; printf '\n'; else "$@"; fi } - run env "IMG=$image:$ENTERPRISE_VERSION" task image:push - run env "VERSION=$ENTERPRISE_VERSION" "CHART_REGISTRY=$chart" task helm:push + run env "VERSION=$ENTERPRISE_VERSION" task helm:package + run env "IMG=$IMAGE_REPOSITORY:$ENTERPRISE_VERSION" task image:push + run env "VERSION=$ENTERPRISE_VERSION" "CHART_REGISTRY=$CHART_REGISTRY" task helm:push printf '\nDeployment (requires cert-manager and a capsule-enterprise image pull secret):\n' printf 'helm upgrade --install scheduled-action %q --namespace capsule-system --create-namespace --version %q --set-string manager.image.reference=%q --set %q\n' \ - "$chart/change-windows" "$ENTERPRISE_VERSION" "$image:$ENTERPRISE_VERSION" 'serviceAccount.imagePullSecrets[0]=capsule-enterprise' + "$CHART_REGISTRY/change-windows" "$ENTERPRISE_VERSION" "$IMAGE_REPOSITORY:$ENTERPRISE_VERSION" 'serviceAccount.imagePullSecrets[0]=capsule-enterprise' BASH enterprise:prerelease: diff --git a/api/v1alpha1/changewindow_types.go b/api/v1alpha1/changewindow_types.go index 62d84c4..116db00 100644 --- a/api/v1alpha1/changewindow_types.go +++ b/api/v1alpha1/changewindow_types.go @@ -3,9 +3,21 @@ package v1alpha1 -import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +import ( + capmeta "github.com/projectcapsule/capsule/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) -const ConditionReady = "Ready" +const ( + ConditionReady = capmeta.ReadyCondition + ConditionActive = "Active" + ReasonPlanned = "Planned" + ReasonActive = capmeta.ActiveReason + ReasonFinished = "Finished" + ReasonFinishing = "Finishing" + ReasonUnscheduled = "Unscheduled" + ReasonUnknown = "Unknown" +) // ChangeWindowSpec selects namespaces and resources for scheduled behavior. // +kubebuilder:validation:XValidation:rule="!has(self.actions) || size(self.actions) == 0 || has(self.serviceAccount)",message="actions require a serviceAccount" @@ -239,6 +251,7 @@ type AdmissionWarning struct { type ChangeWindowStatus struct { // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` + // Active mirrors the Active condition for compatibility. Conditions distinguish unknown state. // +optional Active bool `json:"active,omitempty"` // +optional @@ -246,11 +259,17 @@ type ChangeWindowStatus struct { // WindowEnd is absent when the effective end is beyond the bounded scheduling horizon. // +optional WindowEnd *metav1.Time `json:"windowEnd,omitempty"` + // NextTransition is the active window's end or the next planned start, including after Finished. // +optional NextTransition *metav1.Time `json:"nextTransition,omitempty"` // PendingResources counts journal entries still requiring completion. // +optional PendingResources int32 `json:"pendingResources,omitempty"` + // Conditions report reconciliation readiness (Ready) and schedule occupancy (Active). + // Active reasons are Planned before the first observed window, Active during a window, + // Finishing while restoration is outstanding, and Finished after an observed window completes. + // Finished persists until another window starts. Unknown denotes invalid policy; + // Unscheduled means no occurrence is available and none has been observed. // +listType=map // +listMapKey=type // +optional @@ -261,7 +280,10 @@ type ChangeWindowStatus struct { // +kubebuilder:subresource:status // +kubebuilder:resource:scope=Cluster,shortName=cw // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=".status.conditions[?(@.type=='Ready')].status" -// +kubebuilder:printcolumn:name="Active",type=boolean,JSONPath=".status.active" +// +kubebuilder:printcolumn:name="State",type=string,JSONPath=".status.conditions[?(@.type=='Active')].reason" +// +kubebuilder:printcolumn:name="Next Transition",type=string,JSONPath=".status.nextTransition" +// +kubebuilder:printcolumn:name="Active",type=string,JSONPath=".status.conditions[?(@.type=='Active')].status",priority=1 +// +kubebuilder:printcolumn:name="Pending",type=integer,JSONPath=".status.pendingResources",priority=1 // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=".metadata.creationTimestamp" // ChangeWindow describes scheduled resource changes and admission restrictions. diff --git a/charts/change-windows/crds/addons.projectcapsule.dev_changewindows.yaml b/charts/change-windows/crds/addons.projectcapsule.dev_changewindows.yaml index 24a30ee..c2efe45 100644 --- a/charts/change-windows/crds/addons.projectcapsule.dev_changewindows.yaml +++ b/charts/change-windows/crds/addons.projectcapsule.dev_changewindows.yaml @@ -20,9 +20,20 @@ spec: - jsonPath: .status.conditions[?(@.type=='Ready')].status name: Ready type: string - - jsonPath: .status.active + - jsonPath: .status.conditions[?(@.type=='Active')].reason + name: State + type: string + - jsonPath: .status.nextTransition + name: Next Transition + type: string + - jsonPath: .status.conditions[?(@.type=='Active')].status name: Active - type: boolean + priority: 1 + type: string + - jsonPath: .status.pendingResources + name: Pending + priority: 1 + type: integer - jsonPath: .metadata.creationTimestamp name: Age type: date @@ -640,8 +651,16 @@ spec: status: properties: active: + description: Active mirrors the Active condition for compatibility. + Conditions distinguish unknown state. type: boolean conditions: + description: |- + Conditions report reconciliation readiness (Ready) and schedule occupancy (Active). + Active reasons are Planned before the first observed window, Active during a window, + Finishing while restoration is outstanding, and Finished after an observed window completes. + Finished persists until another window starts. Unknown denotes invalid policy; + Unscheduled means no occurrence is available and none has been observed. items: description: Condition contains details for one aspect of the current state of this API Resource. @@ -701,6 +720,8 @@ spec: - type x-kubernetes-list-type: map nextTransition: + description: NextTransition is the active window's end or the next + planned start, including after Finished. format: date-time type: string observedGeneration: diff --git a/docs/content/docs/development/contributing.md b/docs/content/docs/development/contributing.md index 2423a2e..faef107 100644 --- a/docs/content/docs/development/contributing.md +++ b/docs/content/docs/development/contributing.md @@ -90,8 +90,8 @@ devbox run -- task dev:deploy DEV_CONTEXT=kind-my-dev DEV_KIND_CLUSTER=my-dev Run these commands from the repository root. For repeated commands, enter `devbox shell` once and use `task dev:deploy` inside it. A bare `task` outside Devbox does not load the pinned tools: errors such as a missing `controller-gen` -mean the development environment is not active. The task checks its required -tools before generating code, building an image, or accessing the cluster. +mean the development environment is not active. Task uses the tools on PATH; +Devbox owns their versions, and command failures stop dependent steps. Kind is the default `DEV_IMAGE_MODE`. The task verifies that the selected context points to that Kind cluster, builds for its node architecture, and uses a fresh @@ -270,7 +270,9 @@ CI installs Chromium with its system dependencies and runs the same tests. Production output is in `dist/docs/`. The Documentation workflow checks the site and uploads a preview artifact for pull requests. Every push to `main` also deploys the checked site to GitHub Pages; it can be rerun manually on `main`. -Builds use the base URL returned by Pages, including a configured custom domain. +Builds use the canonical HTTPS URL from `Taskfile.yml`. Keep it and the custom +domain in `docs/hugo.toml` aligned; an HTTP base URL causes browsers to block +the navigation and search scripts as mixed content. In the repository settings, select **Pages → Build and deployment → Source → GitHub Actions** once. The workflow uses the built-in token and the `github-pages` @@ -304,8 +306,8 @@ GitHub Pages hosts the site; Netlify manages its DNS. The CNAME value is a hostname, without `https://` or a repository path. A `CNAME` file in the generated site is unnecessary for an Actions-based Pages -workflow. Hugo and Task default to the production URL, and CI uses the URL returned -by Configure Pages. Netlify does not need a build or hosting configuration. +workflow. Hugo, Task and CI use the HTTPS production URL. Netlify does not need a +build or hosting configuration. See [GitHub's custom domain setup](https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site) and [Netlify DNS records](https://docs.netlify.com/manage/domains/configure-domains/dns-records/). @@ -315,9 +317,9 @@ and [Netlify DNS records](https://docs.netlify.com/manage/domains/configure-doma If `hugo version` reports an older Homebrew/system binary, use `devbox run -- task docs:serve`. Devbox requires Nix. Alternatively, install Hugo Extended 0.166.0 or newer and Node 24 and put them first on `PATH`. -Documentation tasks reject unsupported Hugo versions before generating or -building files. Use the Task commands to expose the local Sass compiler on -`PATH`; installing the theme does not replace your system Hugo. +Task delegates compatibility checks to Hugo and the theme. Use the Task commands +to expose the local Sass compiler on `PATH`; installing the theme does not replace +your system Hugo. ## Develop the Headlamp plugin @@ -374,7 +376,8 @@ task enterprise:release ENTERPRISE_VERSION=0.1.0 DRY_RUN=true Enterprise release tasks publish images and Helm charts when `DRY_RUN` is omitted. Review the preview and authenticate to the intended registry before intentional publication. Versions use SemVer without a `v` prefix or build metadata. Release -tasks also print deployment instructions. Pull-request checks do not publish releases. +tasks validate and package the chart before publishing an image, and print +deployment instructions. Pull-request checks do not publish releases. ### Provenance and Scorecard setup diff --git a/docs/content/docs/guides/headlamp.md b/docs/content/docs/guides/headlamp.md index e3592a8..4b21b2f 100644 --- a/docs/content/docs/guides/headlamp.md +++ b/docs/content/docs/guides/headlamp.md @@ -4,8 +4,21 @@ weight: 80 --- The companion plugin adds **Change Windows** to Headlamp's main navigation. Its -overview combines a weekly calendar with a searchable table of ChangeWindows. -Open an entry for its schedules, namespace selection, targets, actions and rules. +overview combines weekly, monthly and yearly calendar views with a searchable +table of ChangeWindows. Use **Previous**, **Next** and **Today** to navigate the +selected period. In the yearly view, select a month to open its calendar or a +highlighted day to open its week. Monthly cells show up to three entries, with a +link to the week when more windows overlap. +The table shows controller readiness and lifecycle separately from the wall-clock +preview. Open an entry for Headlamp's standard metadata panel, including labels, +annotations, lifecycle and next transition. Conditions and events follow it, +then matching namespaces, a calendar for that window, schedules, targets, actions +and rules. **View YAML** +opens the resource using Headlamp's standard viewer. + +Weekly and monthly calendars use a compact grid. On mobile, the week becomes a +list of days and the month scrolls horizontally. Both the overview and individual +window calendars support all three views. The calendar displays time in your browser's time zone, while each schedule keeps its configured zone. Overlapping and adjacent windows are merged. Active state @@ -42,10 +55,21 @@ a local archive when packaging. Install it as `capsule-change-windows` using you It is tested against Headlamp 0.45.0. The controller and CRD are separate prerequisites; this UI does not install them. -Grant the viewer read access to ChangeWindows and namespaces. Parent workload -reads are needed for owner matching. The repository includes a sample -`headlamp/deploy/viewer-role.yaml`; bind it deliberately and narrow workload reads -to individual namespaces where appropriate. The plugin uses Headlamp's existing +Grant viewers get/list/watch access to ChangeWindows, get access to namespaces +for maintenance matching, and list/watch access to namespaces for the selection +table. The table shows matching namespaces returned to the signed-in user in the +selected cluster, with protected namespaces excluded. It follows current labels; +it does not claim that these namespaces participated in an earlier action. +Node-only windows do not list namespaces. + +Events require list access to core/v1 Events across namespaces, since ChangeWindows +are cluster-scoped. Requests select the current resource UID so events from a +deleted object with the same name are excluded. Events refresh every 30 seconds +and are limited to 2,000 entries. Missing namespace or event permissions produce +an explicit message in that section; the rest of the detail page remains usable. + +Parent workload reads are needed for owner matching; narrow those permissions to +individual namespaces where appropriate. The plugin uses Headlamp's existing login and never needs a new administrator credential. When using a custom controller namespace, configure it per Headlamp cluster in diff --git a/docs/content/docs/guides/operations.md b/docs/content/docs/guides/operations.md index 1cec654..634adae 100644 --- a/docs/content/docs/guides/operations.md +++ b/docs/content/docs/guides/operations.md @@ -14,11 +14,47 @@ kubectl -n capsule-system logs \ The log command uses the release name and namespace from the [installation guide]({{< relref "/docs/getting-started/installation" >}}). -Check `status.observedGeneration` alongside the `Ready` condition. `active` -reports the reconciled window state; `windowStart`, `windowEnd`, and -`nextTransition` report known boundaries. An absent end can mean a continuous -window extends beyond the scheduling horizon. `pendingResources` counts journal -entries still requiring completion. +`kubectl get changewindows` shows **Ready**, **State**, **Next Transition** and +**Age**. `kubectl get changewindows -o wide` also shows the **Active** condition +and the number of **Pending** resource actions. + +The two standard Kubernetes conditions describe different facts: + +- **Ready=True**, reason `Succeeded`: schedules and resource actions reconciled + successfully for `observedGeneration`. A failure sets `Ready=False` with reason + `ReconcileError`; it does not imply that an active maintenance restriction stopped. +- **Active=True**, reason `Active`: the current time falls inside a scheduled + window. Outside a window it is `False`, with the lifecycle reason below. + Invalid policies produce `Active=Unknown`. + +| State (Active condition reason) | Meaning | +| --- | --- | +| `Planned` | Waiting for the first window observed by this controller. | +| `Active` | A scheduled window is currently active, even if an action failed. | +| `Finishing` | The window ended, but resource restoration remains outstanding. | +| `Finished` | The last observed window and its resource actions finished. This persists between recurring windows until the next window starts. | +| `Unknown` | The policy could not be compiled, so the window state cannot be determined. | +| `Unscheduled` | The cron schedules have no upcoming occurrence and no window was observed. | + +`Next Transition` remains populated after `Finished`: it is the next scheduled +start. During `Active`, it reports the current end. It is displayed as an absolute +UTC timestamp. An absent end can mean a continuous window extends beyond the +bounded scheduling horizon. + +```shell +kubectl wait changewindow/development-downtime --for=condition=Ready --timeout=60s +kubectl wait changewindow/development-downtime --for=condition=Active --timeout=1h +kubectl get changewindow development-downtime -o wide +``` + +Check each condition's `observedGeneration` against `metadata.generation` when +using status for automation. `lastTransitionTime` changes when the condition's +True/False/Unknown value changes. The existing `status.active` boolean remains +available for compatibility; conditions distinguish an unknown schedule from an +inactive one. `windowStart` and `windowEnd` describe the active or next window, +while `pendingResources` counts journal entries still requiring completion. +`Finished` reports a controller-observed cycle, not a historical audit of windows +that elapsed while the controller was unavailable. Admission evaluates wall-clock schedules itself. A status update is not the trigger that enables or disables restrictions. diff --git a/docs/content/docs/reference/api.md b/docs/content/docs/reference/api.md index bbdf36f..352c6b1 100644 --- a/docs/content/docs/reference/api.md +++ b/docs/content/docs/reference/api.md @@ -142,12 +142,12 @@ _Appears in:_ | Field | Description | Default | Validation | | --- | --- | --- | --- | | `observedGeneration` _integer_ | | | Optional: \{\}
| -| `active` _boolean_ | | | Optional: \{\}
| +| `active` _boolean_ | Active mirrors the Active condition for compatibility. Conditions distinguish unknown state. | | Optional: \{\}
| | `windowStart` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta)_ | | | Optional: \{\}
| | `windowEnd` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta)_ | WindowEnd is absent when the effective end is beyond the bounded scheduling horizon. | | Optional: \{\}
| -| `nextTransition` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta)_ | | | Optional: \{\}
| +| `nextTransition` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta)_ | NextTransition is the active window's end or the next planned start, including after Finished. | | Optional: \{\}
| | `pendingResources` _integer_ | PendingResources counts journal entries still requiring completion. | | Optional: \{\}
| -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#condition-v1-meta) array_ | | | Optional: \{\}
| +| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#condition-v1-meta) array_ | Conditions report reconciliation readiness (Ready) and schedule occupancy (Active).
Active reasons are Planned before the first observed window, Active during a window,
Finishing while restoration is outstanding, and Finished after an observed window completes.
Finished persists until another window starts. Unknown denotes invalid policy;
Unscheduled means no occurrence is available and none has been observed. | | Optional: \{\}
| ## Config diff --git a/docs/playwright.config.js b/docs/playwright.config.js index 35d411d..33b739c 100644 --- a/docs/playwright.config.js +++ b/docs/playwright.config.js @@ -13,6 +13,7 @@ export default defineConfig({ }, projects: [ { name: 'desktop', use: { viewport: { width: 1440, height: 1050 } } }, + { name: 'android', use: { ...devices['Pixel 7'] } }, { name: 'mobile', use: { ...devices['iPhone 13'], defaultBrowserType: 'chromium' } }, ], webServer: { diff --git a/docs/tests/home.spec.js b/docs/tests/home.spec.js index 375274e..f53da03 100644 --- a/docs/tests/home.spec.js +++ b/docs/tests/home.spec.js @@ -106,11 +106,13 @@ test('without JavaScript the YAML links and download remain available', async ({ await context.close(); }); -test('search resolves examples beneath the project base URL', async ({ page }) => { +test('search resolves examples beneath the project base URL', async ({ page, isMobile }) => { const index = page.waitForResponse(response => response.url().endsWith('/schedule-actions/search-index.json')); await page.goto(''); expect((await index).ok()).toBeTruthy(); - await page.getByRole('button', { name: 'Search website', exact: true }).click(); + const search = page.getByRole('button', { name: 'Search website', exact: true }); + if (isMobile) await search.tap(); + else await search.click(); await page.locator('.search-text').fill('node pool'); await page.locator('.search-results').getByRole('link', { name: /Freeze a node pool/ }).click(); await expect(page).toHaveURL(/\/schedule-actions\/docs\/examples\/node-pool\/$/); @@ -126,13 +128,13 @@ for (const searchLoads of [true, false]) { const toggle = page.getByRole('button', { name: 'Open main navigation menu', exact: true }); if (isMobile) { await expect(main).toBeHidden(); - await toggle.click(); + await toggle.tap(); await expect(main).toBeVisible(); await expect(page.locator('.offcanvas-backdrop')).toHaveCount(1); - await main.getByRole('button', { name: 'Close', exact: true }).click(); + await main.getByRole('button', { name: 'Close', exact: true }).tap(); await expect(main).toBeHidden(); await expect(page.locator('.offcanvas-backdrop')).toHaveCount(0); - await toggle.click(); + await toggle.tap(); } else { await expect(toggle).toBeHidden(); await expect(main).toBeVisible(); @@ -142,11 +144,11 @@ for (const searchLoads of [true, false]) { if (isMobile) { const section = page.locator('#offcanvasNavSection'); await expect(section).toBeHidden(); - await page.getByRole('button', { name: 'Open section navigation menu', exact: true }).click(); + await page.getByRole('button', { name: 'Open section navigation menu', exact: true }).tap(); await expect(section).toBeVisible(); - await section.getByRole('button', { name: 'Close', exact: true }).click(); + await section.getByRole('button', { name: 'Close', exact: true }).tap(); await expect(section).toBeHidden(); - await page.getByRole('button', { name: 'Open section navigation menu', exact: true }).click(); + await page.getByRole('button', { name: 'Open section navigation menu', exact: true }).tap(); await section.getByRole('link', { name: 'Installation', exact: true }).click(); await expect(page).toHaveURL(/\/schedule-actions\/docs\/getting-started\/installation\/$/); } diff --git a/docs/tests/production.spec.js b/docs/tests/production.spec.js new file mode 100644 index 0000000..a7a52a8 --- /dev/null +++ b/docs/tests/production.spec.js @@ -0,0 +1,73 @@ +import { test, expect } from '@playwright/test'; +import { execFileSync } from 'node:child_process'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { extname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const origin = 'https://change-windows.addons.projectcapsule.dev'; +const types = { + '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', + '.json': 'application/json', '.svg': 'image/svg+xml', '.woff2': 'font/woff2', + '.png': 'image/png', '.ico': 'image/x-icon', +}; +let output; + +test.beforeAll(async () => { + output = await mkdtemp(join(tmpdir(), 'change-windows-docs-https-')); + // Build production assets, including minification and script integrity hashes. + execFileSync('npm', ['run', 'build', '--', '--destination', output], { + cwd: fileURLToPath(new URL('..', import.meta.url)), stdio: 'pipe', + }); +}); +test.afterAll(async () => { if (output) await rm(output, { recursive: true, force: true }); }); + +test('production HTTPS assets support menu and search interactions', async ({ page, isMobile }) => { + const errors = []; + const failures = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('requestfailed', request => failures.push(request.url())); + page.on('console', message => { + if (message.type() === 'error') errors.push(message.text()); + }); + // Serve the generated site under its real HTTPS origin without external network access. + await page.route('**/*', async route => { + const url = new URL(route.request().url()); + if (url.origin !== origin) return route.abort(); + const pathname = decodeURIComponent(url.pathname); + const file = resolve(output, `.${pathname.endsWith('/') ? pathname + 'index.html' : pathname}`); + if (!file.startsWith(output + sep)) return route.abort(); + try { + await route.fulfill({ body: await readFile(file), contentType: types[extname(file)] || 'application/octet-stream' }); + } catch { + await route.fulfill({ status: 404, body: 'Not found' }); + } + }); + const activate = locator => isMobile ? locator.tap() : locator.click(); + await page.goto(origin + '/'); + await expect(page.locator('base')).toHaveAttribute('href', origin + '/'); + const main = page.locator('#offcanvasNavMain'); + if (isMobile) { + await expect(main).toBeHidden(); + await activate(page.getByRole('button', { name: 'Open main navigation menu', exact: true })); + await expect(main).toBeVisible(); + await activate(main.getByRole('button', { name: 'Close', exact: true })); + await expect(main).toBeHidden(); + await activate(page.getByRole('button', { name: 'Open main navigation menu', exact: true })); + } + await activate(main.getByRole('link', { name: 'Documentation', exact: true })); + await expect(page).toHaveURL(origin + '/docs/getting-started/overview/'); + if (isMobile) { + const section = page.locator('#offcanvasNavSection'); + await activate(page.getByRole('button', { name: 'Open section navigation menu', exact: true })); + await expect(section).toBeVisible(); + await activate(section.getByRole('link', { name: 'Installation', exact: true })); + await expect(page).toHaveURL(origin + '/docs/getting-started/installation/'); + } + await activate(page.getByRole('button', { name: 'Search website', exact: true })); + await page.locator('.search-text').fill('node pool'); + await activate(page.locator('.search-results').getByRole('link', { name: /Freeze a node pool/ })); + await expect(page).toHaveURL(origin + '/docs/examples/node-pool/'); + expect(failures).toEqual([]); + expect(errors).toEqual([]); +}); diff --git a/e2e/after_hours_test.go b/e2e/after_hours_test.go index aa7e6b0..1eff82e 100644 --- a/e2e/after_hours_test.go +++ b/e2e/after_hours_test.go @@ -101,7 +101,7 @@ func TestAfterHoursExample(t *testing.T) { if err := admin.Get(ctx, client.ObjectKeyFromObject(stateful), stateful); err != nil { return false, err } - return fresh.Status.Active && fresh.Status.ObservedGeneration == fresh.Generation && meta.IsStatusConditionTrue(fresh.Status.Conditions, api.ConditionReady) && *dep.Spec.Replicas == 0 && *stateful.Spec.Replicas == 0, nil + return meta.IsStatusConditionTrue(fresh.Status.Conditions, api.ConditionActive) && fresh.Status.Active && fresh.Status.ObservedGeneration == fresh.Generation && meta.IsStatusConditionTrue(fresh.Status.Conditions, api.ConditionReady) && *dep.Spec.Replicas == 0 && *stateful.Spec.Replicas == 0, nil }) assertReplicas(t, admin, zero, 0) assertReplicas(t, admin, other, 1) @@ -116,7 +116,8 @@ func TestAfterHoursExample(t *testing.T) { if err := admin.Get(ctx, client.ObjectKeyFromObject(stateful), stateful); err != nil { return false, err } - return !fresh.Status.Active && fresh.Status.PendingResources == 0 && *dep.Spec.Replicas == 2 && *stateful.Spec.Replicas == 3, nil + condition := meta.FindStatusCondition(fresh.Status.Conditions, api.ConditionActive) + return condition != nil && condition.Reason == api.ReasonFinished && condition.ObservedGeneration == fresh.Generation && meta.IsStatusConditionTrue(fresh.Status.Conditions, api.ConditionReady) && !fresh.Status.Active && fresh.Status.PendingResources == 0 && *dep.Spec.Replicas == 2 && *stateful.Spec.Replicas == 3, nil }) assertReplicas(t, admin, zero, 0) assertReplicas(t, admin, other, 1) diff --git a/e2e/status_test.go b/e2e/status_test.go new file mode 100644 index 0000000..f53ce72 --- /dev/null +++ b/e2e/status_test.go @@ -0,0 +1,117 @@ +//go:build e2e + +// Copyright 2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/discovery" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + api "github.com/projectcapsule/schedule-action-controller/api/v1alpha1" +) + +func TestWindowStatus(t *testing.T) { + admin, _ := matchingClients(t) + for _, ns := range []string{"status-selected", "status-other"} { + createMatching(t, admin, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns}}) + } + start := time.Now().UTC().Add(time.Minute).Truncate(time.Minute) + window := &api.ChangeWindow{ObjectMeta: metav1.ObjectMeta{Name: "status-lifecycle"}, Spec: api.ChangeWindowSpec{ + NamespaceSelectors: []metav1.LabelSelector{{MatchLabels: map[string]string{"kubernetes.io/metadata.name": "status-selected"}}}, + Schedules: []api.Schedule{{Name: "daily", Cron: fmt.Sprintf("%d %d * * *", start.Minute(), start.Hour()), TimeZone: "UTC", Duration: metav1.Duration{Duration: 10 * time.Second}}}, + Targets: []api.Target{{Name: "configs", APIVersion: "v1", Kind: "ConfigMap"}}, + Admission: []api.AdmissionRule{{Name: "freeze", When: "Active", Action: "Deny", Message: "Status lifecycle maintenance", RequestMatch: api.RequestMatch{Targets: []string{"configs"}, Operations: []string{"CREATE"}}}}, + }} + createMatching(t, admin, window) + awaitLifecycle(t, admin, window, metav1.ConditionTrue, metav1.ConditionFalse, api.ReasonPlanned) + assertWindowTable(t, window.Name, "True", api.ReasonPlanned, start) + allowed := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "before-window", Namespace: "status-selected"}} + createMatching(t, admin, allowed) + awaitLifecycle(t, admin, window, metav1.ConditionTrue, metav1.ConditionTrue, api.ReasonActive) + assertWindowTable(t, window.Name, "True", api.ReasonActive, start.Add(10*time.Second)) + denied := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "during-window", Namespace: "status-selected"}} + if err := admin.Create(t.Context(), denied); err == nil || !strings.Contains(err.Error(), "Status lifecycle maintenance") { + t.Fatalf("expected maintenance rejection, got %v", err) + } + if err := admin.Get(t.Context(), client.ObjectKeyFromObject(denied), &corev1.ConfigMap{}); !apierrors.IsNotFound(err) { + t.Fatalf("denied object persisted: %v", err) + } + createMatching(t, admin, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "outside-selection", Namespace: "status-other"}}) + awaitLifecycle(t, admin, window, metav1.ConditionTrue, metav1.ConditionFalse, api.ReasonFinished) + assertWindowTable(t, window.Name, "True", api.ReasonFinished, start.Add(24*time.Hour)) + createMatching(t, admin, &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "after-window", Namespace: "status-selected"}}) + + // Executor authorization failure must not hide the wall-clock active window. + failed := window.DeepCopy() + failed.ObjectMeta = metav1.ObjectMeta{Name: "status-executor-error"} + failed.Status = api.ChangeWindowStatus{} + failed.Spec.Schedules[0].Cron = "* * * * *" + failed.Spec.Schedules[0].Duration.Duration = 2 * time.Minute + failed.Spec.Admission = nil + failed.Spec.ServiceAccount = &api.ServiceAccountReference{Name: "status-no-access", Namespace: systemNamespace} + failed.Spec.Actions = []api.ResourceAction{{Name: "patch", Targets: []string{"configs"}, OnStart: &api.ActionStep{Apply: api.Apply{Template: "data:\n touched: forbidden"}}}} + createMatching(t, admin, failed) + awaitLifecycle(t, admin, failed, metav1.ConditionFalse, metav1.ConditionTrue, api.ReasonActive) + if err := admin.Get(t.Context(), client.ObjectKeyFromObject(allowed), allowed); err != nil { + t.Fatal(err) + } + if len(allowed.Data) != 0 { + t.Fatalf("unauthorized executor changed resource: %+v", allowed.Data) + } +} + +func awaitLifecycle(t *testing.T, c client.Client, window *api.ChangeWindow, ready, active metav1.ConditionStatus, reason string) { + t.Helper() + poll(t, func(ctx context.Context) (bool, error) { + fresh := &api.ChangeWindow{} + if err := c.Get(ctx, client.ObjectKeyFromObject(window), fresh); err != nil { + return false, err + } + r := meta.FindStatusCondition(fresh.Status.Conditions, api.ConditionReady) + a := meta.FindStatusCondition(fresh.Status.Conditions, api.ConditionActive) + return fresh.Status.ObservedGeneration == fresh.Generation && r != nil && r.ObservedGeneration == fresh.Generation && r.Status == ready && a != nil && a.ObservedGeneration == fresh.Generation && a.Status == active && a.Reason == reason, nil + }) +} + +func assertWindowTable(t *testing.T, name, ready, state string, next time.Time) { + t.Helper() + disc, err := discovery.NewDiscoveryClientForConfig(ctrl.GetConfigOrDie()) + if err != nil { + t.Fatal(err) + } + raw, err := disc.RESTClient().Get().AbsPath("/apis/"+api.GroupVersion.String()+"/changewindows/"+name). + SetHeader("Accept", "application/json;as=Table;g=meta.k8s.io;v=v1").DoRaw(t.Context()) + if err != nil { + t.Fatal(err) + } + table := &metav1.Table{} + if err := json.Unmarshal(raw, table); err != nil { + t.Fatal(err) + } + if len(table.Rows) != 1 { + t.Fatalf("expected one table row: %s", raw) + } + cells := map[string]any{} + for i, column := range table.ColumnDefinitions { + cells[column.Name] = table.Rows[0].Cells[i] + } + for column, want := range map[string]string{"Name": name, "Ready": ready, "State": state, "Next Transition": next.Format(time.RFC3339)} { + if cells[column] != want { + t.Fatalf("kubectl get column %s=%v, want %s; table=%s", column, cells[column], want, raw) + } + } +} diff --git a/hack/dev/readiness.yaml b/hack/dev/readiness.yaml new file mode 100644 index 0000000..4481473 --- /dev/null +++ b/hack/dev/readiness.yaml @@ -0,0 +1,23 @@ +apiVersion: addons.projectcapsule.dev/v1alpha1 +kind: ChangeWindow +metadata: + generateName: dev-readiness- +spec: + namespaceSelectors: + - matchLabels: + change-windows.dev/readiness: probe + schedules: + - name: probe + cron: '0 0 * * *' + duration: 1m + targets: + - name: configs + apiVersion: v1 + kind: ConfigMap + admission: + - name: probe + targets: [configs] + operations: [UPDATE] + when: Active + action: Deny + message: Readiness probe diff --git a/hack/tooling/check_tools_test.go b/hack/tooling/check_tools_test.go deleted file mode 100644 index e92f2d8..0000000 --- a/hack/tooling/check_tools_test.go +++ /dev/null @@ -1,70 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func TestMissingDevelopmentTools(t *testing.T) { - for _, tc := range []struct { - target, tool, mode string - }{ - {target: "generate", tool: "controller-gen"}, - {target: "manifests", tool: "controller-gen"}, - {target: "dev:deploy", tool: "controller-gen", mode: "kind"}, - {target: "dev:deploy", tool: "go", mode: "kind"}, - {target: "dev:deploy", tool: "ko", mode: "kind"}, - {target: "dev:deploy", tool: "helm", mode: "kind"}, - {target: "dev:deploy", tool: "kubectl", mode: "kind"}, - {target: "dev:deploy", tool: "openssl", mode: "kind"}, - {target: "dev:deploy", tool: "kind", mode: "kind"}, - {target: "dev:deploy", tool: "controller-gen", mode: "registry"}, - } { - t.Run(tc.target+"/"+tc.mode+"/"+tc.tool, func(t *testing.T) { - dir, env := deploymentFixture(t) - bin := filepath.Join(dir, "tools") - // Exclude the developer's PATH so installed binaries cannot hide missing tools. - for _, tool := range []string{"bash", "openssl"} { - binary, err := exec.LookPath(tool) - if err != nil { - t.Fatal(err) - } - if err := os.Symlink(binary, filepath.Join(bin, tool)); err != nil { - t.Fatal(err) - } - } - if err := os.Remove(filepath.Join(bin, tc.tool)); err != nil { - t.Fatal(err) - } - if tc.mode == "registry" { - if err := os.Remove(filepath.Join(bin, "kind")); err != nil { - t.Fatal(err) - } - } - write(t, filepath.Join(dir, "tools.log"), "") - out, err := command(t, dir, append(env, "PATH="+bin), "task", tc.target, "DEV_IMAGE_MODE="+tc.mode) - if err == nil { - t.Fatalf("missing %s was accepted: %s", tc.tool, out) - } - for _, want := range []string{"Missing", "devbox shell", "devbox run --", "DEVELOPMENT.md"} { - if !strings.Contains(out, want) { - t.Errorf("missing recovery instruction %q: %s", want, out) - } - } - if tc.mode == "registry" && strings.Contains(out, "Missing development tool: kind") { - t.Fatal("registry deployment requires Kind") - } - if log := logContents(t, dir); log != "" { - t.Fatalf("tools ran before preflight failed: %s", log) - } - }) - } -} diff --git a/hack/tooling/docs_test.go b/hack/tooling/docs_test.go index e7edcf4..49fba45 100644 --- a/hack/tooling/docs_test.go +++ b/hack/tooling/docs_test.go @@ -11,6 +11,8 @@ import ( "path/filepath" "strings" "testing" + + "sigs.k8s.io/yaml" ) func hugoFixture(t *testing.T) (string, []string) { @@ -29,54 +31,11 @@ description: Generated reference for the ChangeWindow Kubernetes API. ## Example A generated type. `) - hugo := filepath.Join(dir, "tools/hugo") - write(t, hugo, `#!/usr/bin/env bash -if [[ "$1" == version ]]; then - printf '%s\n' "$TEST_HUGO_VERSION" - exit "${TEST_HUGO_VERSION_EXIT:-0}" -fi -printf 'hugo\t%s\n' "$@" >> "$TOOL_LOG" -`) - if err := os.Chmod(hugo, 0o755); err != nil { - t.Fatal(err) - } return dir, env } -func TestHugoVersionCheck(t *testing.T) { - for _, tc := range []struct { - name, version, exit string - accepted bool - }{ - {name: "old-homebrew", version: "hugo v0.134.0+extended darwin/arm64 VendorInfo=brew"}, - {name: "older-patch", version: "hugo v0.165.9+extended linux/amd64"}, - {name: "standard-edition", version: "hugo v0.166.0 linux/amd64"}, - {name: "missing", exit: "127"}, - {name: "malformed", version: "unexpected output"}, - {name: "version-failed", version: "hugo v0.166.0+extended linux/amd64", exit: "1"}, - {name: "pinned", version: "hugo v0.166.0-78400b4+extended darwin/arm64", accepted: true}, - {name: "newer-patch", version: "hugo v0.166.1+extended linux/amd64", accepted: true}, - {name: "newer-minor", version: "hugo v0.167.0+extended+withdeploy linux/amd64", accepted: true}, - {name: "newer-major", version: "hugo v1.0.0+extended linux/amd64", accepted: true}, - } { - t.Run(tc.name, func(t *testing.T) { - dir, env := hugoFixture(t) - env = append(env, "TEST_HUGO_VERSION="+tc.version, "TEST_HUGO_VERSION_EXIT="+tc.exit) - out, err := command(t, dir, env, "task", "docs:toolchain") - if (err == nil) != tc.accepted { - t.Fatalf("accepted=%v, error=%v, output=%s", tc.accepted, err, out) - } - if !tc.accepted && (!strings.Contains(out, "requires Hugo Extended >= 0.166.0") || - !strings.Contains(out, "devbox run -- task docs:serve")) { - t.Fatalf("missing actionable version error: %s", out) - } - }) - } -} - func TestAPIReferenceTasks(t *testing.T) { dir, env := hugoFixture(t) - env = append(env, "TEST_HUGO_VERSION=hugo v0.166.0+extended linux/amd64") reference := filepath.Join(dir, "docs/content/docs/reference/api.md") write(t, reference, "stale reference\n") if out, err := command(t, dir, env, "task", "docs:check"); err == nil || !strings.Contains(out, "API reference is stale") { @@ -97,30 +56,23 @@ func TestAPIReferenceTasks(t *testing.T) { } } -func TestDocumentationTasksCheckHugoBeforeWork(t *testing.T) { +func TestDocumentationTaskCommands(t *testing.T) { for _, task := range []string{"docs:serve", "docs:build", "docs:check", "docs:test:browser"} { t.Run(task, func(t *testing.T) { dir, env := hugoFixture(t) - out, err := command(t, dir, append(env, "TEST_HUGO_VERSION=hugo v0.134.0+extended darwin/arm64"), "task", task) - if err == nil || !strings.Contains(out, "requires Hugo Extended") { - t.Fatalf("old Hugo did not stop %s: %v\n%s", task, err, out) - } - if log := logContents(t, dir); log != "" { - t.Fatalf("unsupported Hugo still generated/built files: %s", log) - } - successful(t, dir, append(env, "TEST_HUGO_VERSION=hugo v0.166.0+extended linux/amd64"), "task", task) + successful(t, dir, env, "task", task) log := logContents(t, dir) if !strings.Contains(log, "npm\tci\t--ignore-scripts") { t.Fatalf("Doks dependencies were not installed: %s", log) } if task != "docs:test:browser" && !strings.Contains(log, "crd-ref-docs\t") { - t.Fatalf("compatible Hugo did not continue %s: %s", task, log) + t.Fatalf("documentation task did not continue %s: %s", task, log) } if task == "docs:check" && !strings.Contains(log, "go\ttest") || task == "docs:test:browser" && !strings.Contains(log, "npm\t--prefix\tdocs\trun\ttest:browser") || task == "docs:build" && !strings.Contains(log, "npm\t--prefix\tdocs\trun\tbuild") || task == "docs:serve" && !strings.Contains(log, "npm\t--prefix\tdocs\trun\tdev") { - t.Fatalf("compatible Hugo did not run %s: %s", task, log) + t.Fatalf("documentation task did not run %s: %s", task, log) } }) } @@ -141,7 +93,6 @@ func TestHugoVersionRequirementsAgree(t *testing.T) { version := config.Packages["hugo"] for file, expected := range map[string]string{ "docs/hugo.toml": "min = '" + version + "'", - "Taskfile.yml": "minimum=" + version + "\n", } { data, err := os.ReadFile(filepath.Join(root, file)) if err != nil || !strings.Contains(string(data), expected) { @@ -154,7 +105,7 @@ func TestDocumentationTasksStopOnInstallFailure(t *testing.T) { for _, task := range []string{"docs:serve", "docs:build", "docs:check", "docs:test:browser"} { t.Run(task, func(t *testing.T) { dir, env := hugoFixture(t) - env = append(env, "TEST_HUGO_VERSION=hugo v0.166.0+extended linux/amd64", "FAIL_TOOL=npm") + env = append(env, "FAIL_TOOL=npm") if out, err := command(t, dir, env, "task", task); err == nil { t.Fatalf("failed dependency installation did not stop %s: %s", task, out) } @@ -165,3 +116,44 @@ func TestDocumentationTasksStopOnInstallFailure(t *testing.T) { }) } } + +func TestDocumentationDeploymentUsesHTTPS(t *testing.T) { + data, err := os.ReadFile(filepath.Join(repository(t), ".github/workflows/docs.yml")) + if err != nil { + t.Fatal(err) + } + var workflow struct { + Env map[string]string + Jobs map[string]struct { + Env map[string]string + Steps []struct { + Run string + Env map[string]string + } + } + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + job := workflow.Jobs["build"] + for _, step := range job.Steps { + if !strings.Contains(step.Run, "task docs:check docs:build") { + continue + } + dir, env := hugoFixture(t) + for _, values := range []map[string]string{workflow.Env, job.Env, step.Env} { + if value, exists := values["DOCS_BASE_URL"]; exists { + if !strings.HasPrefix(value, "https://") { + t.Fatalf("Pages may report HTTP: documentation build must use a known HTTPS base URL, got %q", value) + } + env = append(env, "DOCS_BASE_URL="+value) + } + } + successful(t, dir, env, "task", "docs:build") + if log := logContents(t, dir); !strings.Contains(log, "--baseURL\thttps://change-windows.addons.projectcapsule.dev/") { + t.Fatalf("published Hugo build must resolve its scripts and search index over HTTPS: %s", log) + } + return + } + t.Fatal("documentation build step not found") +} diff --git a/hack/tooling/image_test.go b/hack/tooling/image_test.go index 49a0b65..a5dd7a3 100644 --- a/hack/tooling/image_test.go +++ b/hack/tooling/image_test.go @@ -249,3 +249,57 @@ func workflowStep(t *testing.T, file, job, id string) string { t.Fatalf("missing step %s/%s in %s", job, id, file) return "" } + +func TestEnterpriseRejectsInvalidControls(t *testing.T) { + for _, argument := range []string{"DRY_RUN=tru", "CHANNEL=unknown", "ENTERPRISE_VERSION="} { + t.Run(argument, func(t *testing.T) { + dir, env := fixture(t) + out, err := command(t, dir, env, "task", "enterprise:release", "ENTERPRISE_VERSION=1.2.3", argument) + if err == nil { + t.Fatalf("invalid control accepted: %s", out) + } + if log := logContents(t, dir); strings.Contains(log, "helm\t") || strings.Contains(log, "ko\t") { + t.Fatalf("invalid control reached release tools: %s", log) + } + }) + } +} + +func TestEnterpriseStopsBeforePublishingOnPackageFailure(t *testing.T) { + dir, env := fixture(t) + if out, err := command(t, dir, append(env, "FAIL_TOOL=helm"), "task", "enterprise:release", "ENTERPRISE_VERSION=1.2.3"); err == nil { + t.Fatalf("failed package accepted: %s", out) + } + log := logContents(t, dir) + if !strings.Contains(log, "helm\tpackage") || strings.Contains(log, "ko\t") || strings.Contains(log, "helm\tpush") { + t.Fatalf("publishing ran after package failure: %s", log) + } +} + +func TestChartVersionValidationUsesHelm(t *testing.T) { + helm, err := exec.LookPath("helm") + if err != nil { + t.Fatal(err) + } + for _, version := range []string{"v1.2.3-rc.1", "invalid", "1.2.3; touch unexpected"} { + t.Run(version, func(t *testing.T) { + dir, env := fixture(t) + copyFile(t, repository(t), dir, "charts/change-windows/Chart.yaml") + write(t, filepath.Join(dir, "tools/helm"), "#!/usr/bin/env bash\nexec \"$REAL_HELM\" \"$@\"\n") + out, err := command(t, dir, append(env, "REAL_HELM="+helm), "task", "helm:package", "VERSION="+version) + if version == "v1.2.3-rc.1" { + if err != nil { + t.Fatalf("valid version rejected: %v %s", err, out) + } + if _, err := os.Stat(filepath.Join(dir, "dist/charts/change-windows-1.2.3-rc.1.tgz")); err != nil { + t.Fatal(err) + } + } else if err == nil || !strings.Contains(strings.ToLower(out), "invalid semantic version") { + t.Fatalf("expected Helm's version error: %v %s", err, out) + } + if _, err := os.Stat(filepath.Join(dir, "unexpected")); !os.IsNotExist(err) { + t.Fatal("version executed as shell code") + } + }) + } +} diff --git a/hack/tooling/taskfile_test.go b/hack/tooling/taskfile_test.go index ceb03b7..bf169df 100644 --- a/hack/tooling/taskfile_test.go +++ b/hack/tooling/taskfile_test.go @@ -13,7 +13,9 @@ import ( "path/filepath" "regexp" "strings" + "syscall" "testing" + "time" "sigs.k8s.io/yaml" ) @@ -25,7 +27,8 @@ func TestTaskShell(t *testing.T) { } var taskfile struct { Tasks map[string]struct { - Cmds []any + Cmds []any + Preconditions []struct{ Sh string } } } if err := yaml.Unmarshal(data, &taskfile); err != nil { @@ -33,7 +36,11 @@ func TestTaskShell(t *testing.T) { } template := regexp.MustCompile("{{.*?}}") for name, task := range taskfile.Tasks { - for index, command := range task.Cmds { + commands := append([]any(nil), task.Cmds...) + for _, precondition := range task.Preconditions { + commands = append(commands, precondition.Sh) + } + for index, command := range commands { source, ok := command.(string) if !ok { mapping, ok := command.(map[string]any) @@ -182,3 +189,81 @@ func TestManifestsTargetHelm(t *testing.T) { t.Fatalf("manifest generation must not recreate config/: %v", err) } } + +func TestE2EStopsWhenClusterDiscoveryFails(t *testing.T) { + dir, env := deploymentFixture(t) + out, err := command(t, dir, append(env, "FAIL_TOOL=kind"), "task", "e2e", "KIND_CLUSTER=isolated") + if err == nil { + t.Fatalf("failed discovery accepted: %s", out) + } + log := logContents(t, dir) + if !strings.Contains(log, "kind\tget\tclusters") || strings.Contains(log, "kind\tcreate") || strings.Contains(log, "kind\tdelete") || strings.Contains(log, "ko\t") { + t.Fatalf("failed cluster discovery did not stop setup: %s", log) + } +} + +func TestE2ESignalCleanup(t *testing.T) { + dir, env := fixture(t) + kind := filepath.Join(dir, "tools/kind") + write(t, kind, `#!/usr/bin/env bash +set -euo pipefail +printf 'kind\t%s\t%s\n' "$1" "$2" >> "$TOOL_LOG" +if [[ "$1 $2" == 'create cluster' ]]; then + printf '%s' "$KUBECONFIG" > "$PRIVATE_CONFIG_RECORD" + trap 'exit 143' TERM INT + while true; do sleep 0.1; done +fi +`) + if err := os.Chmod(kind, 0o755); err != nil { + t.Fatal(err) + } + record := filepath.Join(dir, "private-config") + output, err := os.Create(filepath.Join(dir, "output.log")) + if err != nil { + t.Fatal(err) + } + defer output.Close() + cmd := exec.Command("task", "e2e", "KIND_CLUSTER=isolated") + cmd.Dir = dir + cmd.Env = append(os.Environ(), append(env, "PRIVATE_CONFIG_RECORD="+record)...) + cmd.Stdout, cmd.Stderr = output, output + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }) + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + deadline := time.Now().Add(10 * time.Second) + var config []byte + for time.Now().Before(deadline) { + config, _ = os.ReadFile(record) + if len(config) > 0 { + break + } + time.Sleep(20 * time.Millisecond) + } + if len(config) == 0 { + t.Fatal("cluster creation did not start") + } + if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil { + t.Fatal("interrupted e2e reported success") + } + case <-time.After(10 * time.Second): + t.Fatal("interrupted e2e did not terminate") + } + log := logContents(t, dir) + for _, call := range []string{"kind\texport\tlogs", "kind\tdelete\tcluster"} { + if !strings.Contains(log, call) { + t.Errorf("missing cleanup %q: %s", call, log) + } + } + if _, err := os.Stat(string(config)); !os.IsNotExist(err) { + t.Fatalf("temporary credentials retained: %v", err) + } +} diff --git a/hack/tooling/tooling_test.go b/hack/tooling/tooling_test.go index 69595fd..5854151 100644 --- a/hack/tooling/tooling_test.go +++ b/hack/tooling/tooling_test.go @@ -67,7 +67,7 @@ func fixture(t *testing.T) (string, []string) { t.Fatal(err) } root := repository(t) - for _, name := range []string{"Taskfile.yml", "prek.toml", "hack/prek-builtins.toml"} { + for _, name := range []string{"Taskfile.yml", "prek.toml", "hack/prek-builtins.toml", "hack/dev/readiness.yaml"} { copyFile(t, root, dir, name) } successful(t, dir, nil, "git", "init", "-q") @@ -241,9 +241,6 @@ func TestEnterpriseTasks(t *testing.T) { if strings.Contains(log, "helm\t") || strings.Contains(log, "ko\t") { t.Fatal("dry run executed publishing tools") } - if _, err := command(t, dir, env, "task", "enterprise:"+channel, "ENTERPRISE_VERSION=v0.1.0", "DRY_RUN=true"); err == nil { - t.Fatal("invalid enterprise version accepted") - } }) } } diff --git a/headlamp/README.md b/headlamp/README.md index 8e55147..48d2274 100644 --- a/headlamp/README.md +++ b/headlamp/README.md @@ -4,10 +4,13 @@ This plugin adds a top-level **Change Windows** navigation entry for the Capsule Scheduled Action Controller. It is independent of the Capsule Headlamp plugin. Tested against Headlamp 0.45.0 with the pinned 0.14.0 plugin SDK. -- Weekly calendar with previous/next/today navigation and local-time labels. +- Compact weekly, monthly and yearly calendars on the overview and each + ChangeWindow detail page, with previous/next/today navigation. + Select a month or highlighted day in the yearly view to explore its windows. - Searchable table of all ChangeWindows, active windows, next transitions, controller readiness, target kinds, and pending restoration counts. -- Detail pages for schedules, selectors, resource actions, admission and the CR. +- Standard Headlamp detail pages with metadata first, conditions, events, matching + visible namespaces, schedules, resource actions, admission and View YAML. - A **Maintenance windows** section on namespaced resource detail pages. It matches direct GVK/label targets and follows supported controlling workload owners, including Pod → ReplicaSet → Deployment. @@ -70,13 +73,23 @@ protection must permit the release job's `GITHUB_TOKEN` to push to that branch. Select one cluster to open the overview or an action. Requests use Headlamp's authenticated user and explicit cluster context. The -plugin never reads journal Secrets or writes workloads. The optional -[`viewer-role.yaml`](deploy/viewer-role.yaml) lists its additional read permissions; -review and bind it to the intended users/groups. It includes cluster-scoped -ChangeWindow list/watch/get, namespace get, and keyed reads of supported -workload owners. The user's normal Headlamp permissions still govern Pod and -other resource detail pages. In restricted environments, grant parent-resource -reads using namespace-scoped Roles instead of the sample's cluster-wide grants. +plugin never reads journal Secrets or writes workloads. Viewers need ChangeWindow +get/list/watch, namespace get for maintenance matching, and namespace list/watch +for the selection table. Events require list access to core/v1 Events across +namespaces because ChangeWindows are cluster-scoped. Event requests filter by +resource UID, refresh every 30 seconds and show at most 2,000 entries. Permission +errors are shown in their section without hiding the remaining details. + +The namespace table contains only namespaces returned to the signed-in user in +the selected cluster that satisfy the window's selectors. It follows label and +selector updates, excludes protected namespaces, and links to namespace details. +Node-only windows do not request a namespace list. An empty successful list is +shown separately from loading or permission errors. This describes current +selection, not the controller's recorded action participants. + +The user's normal Headlamp permissions govern resource detail pages and keyed +reads of workload owners. Grant parent-resource reads using namespace-scoped +Roles where appropriate; the plugin does not grant or expand permissions. Namespace selectors are ORed; labels within each selector use Kubernetes AND semantics, including `NotIn` on absent labels. Namespace identity and target GVK @@ -100,13 +113,20 @@ occurrences. Active state uses the browser clock, independent of delayed status; controller readiness is displayed separately. Check clock synchronization when comparing the preview with admission decisions. -Schedules are projected over bounded ranges with a 14-day margin. A transition +The detail calendar previews only the selected ChangeWindow. Weekly and monthly +views use a continuous grid with compact entries; on narrow screens the week +becomes a list of days and the month scrolls horizontally. +Schedules are projected over the selected week, month or year with a 14-day margin. +Projection is bounded to 400 days, including leap years and margins. Monthly cells +show up to three entries and link to the week for more detail; the yearly view +highlights days with windows without rendering every event. A transition beyond the current preview is labelled accordingly. At most 250 matching actions and 10,000 intervals per action are expanded; dense or invalid projections show an explicit warning/unknown state. The table is paginated in groups of 50 and still includes all actions. Filter by action name or kind to narrow the preview. Continuous minutely schedules are collapsed into a single interval. Projection -results are memoized across clock ticks; resource detail pages first filter +work yields between batches and is cancelled when you navigate or filter. Results +are retained across clock ticks; resource detail pages first filter policies by their namespace and target scope. The view shows current selection, not durable participation or completed patches. diff --git a/headlamp/e2e/headlamp.spec.ts b/headlamp/e2e/headlamp.spec.ts index 2543d4e..9b07631 100644 --- a/headlamp/e2e/headlamp.spec.ts +++ b/headlamp/e2e/headlamp.spec.ts @@ -83,6 +83,54 @@ test.beforeEach(async ({ page }) => { items: [action], }); if (path.endsWith(`/changewindows/${action.metadata.name}`)) return json(action); + if (path.endsWith('/namespaces')) + return json({ + apiVersion: 'v1', + kind: 'NamespaceList', + metadata: { resourceVersion: '1' }, + items: [ + namespaceFixture, + { + ...namespaceFixture, + metadata: { name: 'kube-system', labels: namespaceFixture.metadata.labels }, + }, + { + ...namespaceFixture, + metadata: { name: 'production', labels: { environment: 'production' } }, + }, + ], + }); + if (path.endsWith('/events')) + return json({ + apiVersion: 'v1', + kind: 'EventList', + metadata: { resourceVersion: '1' }, + items: [ + { + apiVersion: 'v1', + kind: 'Event', + metadata: { + name: 'observed-window', + uid: 'event-a', + namespace: 'default', + creationTimestamp: '2026-10-01T10:00:00Z', + }, + involvedObject: { kind: 'ChangeWindow', name: action.metadata.name, uid: 'action-a' }, + type: 'Normal', + reason: 'Observed', + message: 'Observed this window', + source: { component: 'fixture' }, + lastTimestamp: '2026-10-01T10:00:00Z', + }, + { + apiVersion: 'v1', + kind: 'Event', + metadata: { name: 'old-window', uid: 'event-old' }, + involvedObject: { kind: 'ChangeWindow', name: action.metadata.name, uid: 'old-uid' }, + message: 'Previous resource event', + }, + ], + }); if (path.endsWith('/namespaces/development')) return json(namespaceFixture); if (path.endsWith('/pods/web-pod')) return json(pod); if (path.endsWith('/replicasets/web-rs')) return json(replicaSet); @@ -165,3 +213,274 @@ test('keeps the calendar navigable at a narrow viewport', async ({ page }) => { await page.getByRole('button', { name: 'Today', exact: true }).click(); await expect(page.getByRole('table', { name: 'Change windows' })).toBeVisible(); }); + +for (const mobile of [false, true]) { + test(`switches calendar periods and drills down (mobile: ${mobile})`, async ({ page }) => { + if (mobile) await page.setViewportSize({ width: 390, height: 844 }); + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await page.goto('/c/main/change-windows'); + await page.getByRole('button', { name: 'Monthly', exact: true }).click(); + await expect(page.getByRole('region', { name: 'Monthly action calendar' })).toBeVisible(); + const current = await page.getByTestId('calendar-period').innerText(); + await page.getByRole('button', { name: 'Next month' }).click(); + await expect(page.getByTestId('calendar-period')).not.toHaveText(current); + await page.getByRole('button', { name: 'Previous month' }).click(); + await expect(page.getByTestId('calendar-period')).toHaveText(current); + await page.getByRole('button', { name: 'Yearly', exact: true }).click(); + const year = await page.getByTestId('calendar-period').innerText(); + await expect(page.getByRole('region', { name: 'Yearly action calendar' })).toBeVisible(); + await page.getByRole('button', { name: 'Next year' }).click(); + await expect(page.getByTestId('calendar-period')).toHaveText(String(Number(year) + 1)); + await page.getByRole('button', { name: `Open February ${Number(year) + 1}` }).click(); + await expect(page.getByRole('region', { name: 'Monthly action calendar' })).toBeVisible(); + await page.getByRole('button', { name: 'Today', exact: true }).click(); + await expect(page.getByTestId('calendar-period')).toHaveText(current); + await page.getByRole('button', { name: 'Yearly', exact: true }).click(); + await page + .getByRole('button', { name: /: 1 window. Open week/ }) + .first() + .click(); + await expect(page.getByRole('region', { name: 'Weekly action calendar' })).toBeVisible(); + const table = page.getByRole('table', { name: 'Change windows' }); + await table.getByRole('link', { name: action.metadata.name }).click(); + await expect(page.getByRole('table', { name: 'Action schedules' })).toBeVisible(); + expect(errors).toEqual([]); + }); +} + +test('keeps navigation available while projecting a large year', async ({ page }) => { + const items = Array.from({ length: 100 }, (_, index) => { + const item = actionFixture(); + item.metadata.name = `daily-${index}`; + item.metadata.uid = `daily-${index}`; + item.spec.schedules = [ + { + name: 'daily', + cron: `${index % 60} ${10 + Math.floor(index / 60)} * * *`, + duration: '2h', + timeZone: 'Europe/Zurich', + }, + ]; + return item; + }); + await page.route('**/apis/addons.projectcapsule.dev/v1alpha1/changewindows*', route => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify({ + apiVersion: action.apiVersion, + kind: 'ChangeWindowList', + metadata: { resourceVersion: '1' }, + items, + }), + }) + ); + await page.goto('/c/main/change-windows'); + await expect(page.getByRole('region', { name: 'Weekly action calendar' })).toBeVisible(); + await page.getByRole('button', { name: 'Yearly', exact: true }).click(); + await expect(page.getByText('Calculating calendar…', { exact: true })).toBeVisible(); + await page.getByRole('button', { name: 'Weekly', exact: true }).click(); + await expect(page.getByRole('region', { name: 'Weekly action calendar' })).toBeVisible(); + await expect(page.getByRole('region', { name: 'Yearly action calendar' })).toBeHidden(); +}); + +test('shows completed lifecycle and conditions without treating inactivity as a failure', async ({ + page, +}) => { + await page.clock.install({ time: new Date('2026-10-01T13:00:00Z') }); + const completed = actionFixture(); + completed.status = { + observedGeneration: 1, + active: false, + nextTransition: '2026-10-02T10:00:00Z', + conditions: [ + { + type: 'Ready', + status: 'True', + reason: 'Succeeded', + observedGeneration: 1, + lastTransitionTime: '2026-10-01T10:00:00Z', + }, + { + type: 'Active', + status: 'False', + reason: 'Finished', + observedGeneration: 1, + lastTransitionTime: '2026-10-01T12:00:00Z', + message: 'The last observed window has finished.', + }, + ], + }; + await page.route( + /\/apis\/addons\.projectcapsule\.dev\/v1alpha1\/changewindows(?:\/|\?|$)/, + route => + route.fulfill({ + contentType: 'application/json', + body: JSON.stringify( + new URL(route.request().url()).pathname.endsWith('/changewindows') + ? { + apiVersion: completed.apiVersion, + kind: 'ChangeWindowList', + metadata: { resourceVersion: '1' }, + items: [completed], + } + : completed + ), + }) + ); + await page.goto('/c/main/change-windows'); + const table = page.getByRole('table', { name: 'Change windows' }); + await expect(table.getByRole('cell', { name: 'Finished', exact: true })).toBeVisible(); + await expect(table.getByText('Inactive', { exact: true })).toBeVisible(); + await table.getByRole('link', { name: completed.metadata.name }).click(); + await expect( + page.getByRole('region', { name: 'Conditions' }).getByText('Finished', { exact: true }) + ).toBeVisible(); + await expect( + page.getByRole('alert').filter({ hasText: 'The last observed window has finished.' }) + ).toHaveCount(0); + await expect(page.getByRole('main').locator('dl').first()).toContainText('Next transition'); + await expect(page.getByRole('main').locator('dl').first()).not.toContainText('Not reported'); +}); + +test('uses native metadata, conditions, events and matching visible namespaces', async ({ + page, +}) => { + const errors: string[] = []; + const reads: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('request', request => { + if (/\/api\/v1\/(events|namespaces)(?:\?|$)/.test(request.url())) reads.push(request.url()); + }); + await page.goto(`/c/main/change-windows/${action.metadata.name}`); + const main = page.getByRole('main'); + const metadata = main.locator('dl').first(); + await expect(metadata).toContainText('Name'); + await expect(metadata).toContainText(action.metadata.name); + await expect(metadata).toContainText('Creation'); + await expect(metadata).toContainText('Labels'); + await expect(metadata).toContainText('platform'); + await expect(metadata).toContainText('Annotations'); + await expect(metadata).toContainText('Workload maintenance'); + await expect(page.getByRole('button', { name: 'View YAML', exact: true })).toBeVisible(); + await expect( + page + .getByRole('region', { name: 'Conditions', exact: true }) + .getByText('Ready', { exact: true }) + ).toBeVisible(); + const events = page.getByRole('region', { name: 'Events', exact: true }); + await expect(events).toContainText('Observed this window'); + await expect(events).not.toContainText('Previous resource event'); + const namespaces = page.getByRole('region', { name: 'Namespace selection', exact: true }); + await expect(namespaces.getByRole('link', { name: 'development', exact: true })).toHaveAttribute( + 'href', + '/c/main/namespaces/development' + ); + await expect(namespaces.getByRole('link')).toHaveCount(1); + await expect(namespaces).not.toContainText('kube-system'); + await expect(namespaces).not.toContainText('production'); + expect( + reads + .filter(url => /\/events(?:\?|$)/.test(url)) + .every( + url => new URL(url).searchParams.get('fieldSelector') === 'involvedObject.uid=action-a' + ) + ).toBe(true); + expect(reads.filter(url => /\/namespaces(?:\?|$)/.test(url))).toHaveLength(1); + expect(errors).toEqual([]); + await page.screenshot({ + path: 'test-results/standard-change-window-details.png', + fullPage: true, + }); +}); + +for (const status of [403, 500]) { + test(`keeps details usable when namespace and event lists fail (${status})`, async ({ page }) => { + await page.route(/\/api\/v1\/(namespaces|events)(?:\?|$)/, route => + route.fulfill({ + status, + contentType: 'application/json', + body: JSON.stringify({ + kind: 'Status', + status: 'Failure', + reason: status === 403 ? 'Forbidden' : 'InternalError', + message: 'Fixture list unavailable', + code: status, + }), + }) + ); + await page.goto(`/c/main/change-windows/${action.metadata.name}`); + await expect(page.getByRole('table', { name: 'Action schedules' })).toBeVisible(); + for (const name of ['Namespace selection', 'Events']) { + const section = page.getByRole('region', { name, exact: true }); + // Shared Headlamp queries retry 5xx responses after 1, 2 and 4 seconds. + await expect(section.getByRole('alert')).toContainText( + status === 403 ? 'permission' : 'Unable to load', + { timeout: status === 500 ? 10000 : 5000 } + ); + await expect(section.getByRole('table')).toHaveCount(0); + } + await expect( + page + .getByRole('region', { name: 'Conditions', exact: true }) + .getByText('Ready', { exact: true }) + ).toBeVisible(); + }); +} + +for (const mobile of [false, true]) { + test( + 'shows a compact detail calendar with all views (mobile: ' + mobile + ')', + async ({ page }) => { + await page.clock.install({ time: new Date('2026-10-01T11:00:00Z') }); + if (mobile) await page.setViewportSize({ width: 390, height: 844 }); + const errors: string[] = []; + const listReads: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('request', request => { + if (/\/changewindows(?:\?|$)/.test(request.url())) listReads.push(request.url()); + }); + await page.goto('/c/main/change-windows/' + action.metadata.name); + const week = page.getByRole('region', { name: 'Weekly action calendar' }); + await expect(week).toBeVisible(); + await week.scrollIntoViewIfNeeded(); + await expect(week.getByRole('group')).toHaveCount(7); + await expect(week.getByText(action.metadata.name, { exact: true })).toHaveCount(7); + const weekSize = (await week.boundingBox())!; + expect(weekSize.height).toBeLessThan(mobile ? 480 : 180); + if (mobile) { + expect(weekSize.x).toBeGreaterThanOrEqual(0); + expect(weekSize.x + weekSize.width).toBeLessThanOrEqual(390); + } + await week.screenshot({ path: 'test-results/compact-detail-week-' + mobile + '.png' }); + await page.getByRole('button', { name: 'Monthly', exact: true }).click(); + const month = page.getByRole('region', { name: 'Monthly action calendar' }); + await expect(month).toBeVisible(); + await expect(month.getByRole('group')).toHaveCount(35); + expect((await month.boundingBox())!.height).toBeLessThan(650); + await month.scrollIntoViewIfNeeded(); + if (mobile) { + // Month overflow stays inside its scroll area; the whole page must still fit. + const scroll = await month.evaluate(element => { + const parent = element.parentElement!; + parent.scrollLeft = parent.scrollWidth; + return { offset: parent.scrollLeft, overflow: getComputedStyle(parent).overflowX }; + }); + expect(scroll.offset).toBeGreaterThan(0); + expect(scroll.overflow).toBe('auto'); + } else { + await month.screenshot({ path: 'test-results/compact-detail-month.png' }); + } + await page.getByRole('button', { name: 'Yearly', exact: true }).click(); + await expect(page.getByRole('region', { name: 'Yearly action calendar' })).toBeVisible(); + await page.getByRole('button', { name: 'Open November 2026' }).click(); + await expect(page.getByTestId('calendar-period')).toHaveText('November 2026'); + await page.getByRole('button', { name: 'Today', exact: true }).click(); + await expect(page.getByTestId('calendar-period')).toHaveText('October 2026'); + await page.getByRole('button', { name: 'Weekly', exact: true }).click(); + await expect(week).toBeVisible(); + expect(listReads).toEqual([]); + expect(errors).toEqual([]); + } + ); +} diff --git a/headlamp/src/Details.tsx b/headlamp/src/Details.tsx index 967566a..357a1d3 100644 --- a/headlamp/src/Details.tsx +++ b/headlamp/src/Details.tsx @@ -1,10 +1,9 @@ -import { Link, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { Resource, SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; import { getCluster } from '@kinvolk/headlamp-plugin/lib/Utils'; import { Alert, Box, Chip, - CircularProgress, Stack, Table, TableBody, @@ -15,10 +14,13 @@ import { } from '@mui/material'; import { useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { readyStatus, useCurrentProjections, useNow } from './hooks'; -import { formatTime, LoadError, ROUTE } from './Overview'; +import { lifecycleStatus, readyStatus, useCurrentProjections, useNow } from './hooks'; +import { NamespaceSelection } from './NamespaceSelection'; +import { formatTime } from './Overview'; import { ChangeWindow } from './resources'; import { currentWindow, HORIZON } from './schedule'; +import { WindowCalendar } from './WindowCalendar'; +import { WindowEvents } from './WindowEvents'; export function Details() { const cluster = getCluster(); @@ -29,30 +31,63 @@ export function Details() { function ClusterDetails({ cluster }: { cluster: string }) { const { name } = useParams<{ name: string }>(); - const [item, error] = ChangeWindow.useGet(name, undefined, { cluster }); + return ( + (item ? [] : [])} + extraInfo={item => + item + ? [ + { name: 'Controller', value: readyStatus(item.jsonData) }, + { name: 'Lifecycle', value: lifecycleStatus(item.jsonData) }, + { + name: 'Next transition', + value: item.jsonData.status?.nextTransition + ? formatTime(Date.parse(item.jsonData.status.nextTransition)) + : 'Not reported', + }, + { name: 'Pending resources', value: item.jsonData.status?.pendingResources || 0 }, + ] + : [] + } + extraSections={item => + item + ? [ + { + id: 'capsule-change-windows-details', + section: ( + + ), + }, + ] + : [] + } + /> + ); +} + +function WindowSections({ item, cluster }: { item: ChangeWindow; cluster: string }) { const actions = useMemo(() => (item ? [item.jsonData] : []), [item]); const now = useNow(); const projections = useCurrentProjections(actions, now); - if (error) return ; - if (!item) return ; const action = item.jsonData; const projection = projections[0]; const active = currentWindow(projection.windows, now); return ( - - - Change Windows - - - {action.metadata.name} - + - - {projection.error && {projection.error}} {active && ( @@ -65,12 +100,20 @@ function ClusterDetails({ cluster }: { cluster: string }) { )} {action.status?.conditions - ?.filter(condition => condition.status !== 'True') + ?.filter(condition => + condition.type === 'Active' ? condition.status === 'Unknown' : condition.status !== 'True' + ) .map(condition => ( {condition.type}: {condition.message || condition.reason} ))} + + + + + + action.metadata.name} /> @@ -95,11 +138,6 @@ function ClusterDetails({ cluster }: { cluster: string }) {
- - - {JSON.stringify(action.spec.namespaceSelectors, null, 2)} - - @@ -160,11 +198,6 @@ function ClusterDetails({ cluster }: { cluster: string }) { ))} - - - {JSON.stringify(action, null, 2)} - - ); } diff --git a/headlamp/src/NamespaceSelection.tsx b/headlamp/src/NamespaceSelection.tsx new file mode 100644 index 0000000..4cd7932 --- /dev/null +++ b/headlamp/src/NamespaceSelection.tsx @@ -0,0 +1,94 @@ +import { K8s } from '@kinvolk/headlamp-plugin/lib'; +import { + Link, + Resource, + SectionBox, + SimpleTable, +} from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import type Namespace from '@kinvolk/headlamp-plugin/lib/k8s/namespace'; +import { Alert, CircularProgress, Typography } from '@mui/material'; +import { useMemo } from 'react'; +import { DEFAULT_SYSTEM_NAMESPACE, matchesNamespace } from './matching'; +import { useSettings } from './settings'; +import type { ChangeWindowData } from './types'; + +export function NamespaceSelection({ + action, + cluster, +}: { + action: ChangeWindowData; + cluster: string; +}) { + return ( + + {action.spec.namespaceSelectors?.length ? ( + + ) : ( + + No namespace selectors are configured. Node-only windows select Nodes directly. + + )} + + ); +} + +function SelectedNamespaces({ action, cluster }: { action: ChangeWindowData; cluster: string }) { + const [namespaces, error] = K8s.ResourceClasses.Namespace.useList({ cluster }); + const config = useSettings(); + const systemNamespace = config?.systemNamespaces?.[cluster] || DEFAULT_SYSTEM_NAMESPACE; + const selected = useMemo( + () => + namespaces?.filter( + namespace => + namespace.cluster === cluster && + matchesNamespace(action, namespace.jsonData, systemNamespace) + ) ?? null, + [namespaces, action, systemNamespace, cluster] + ); + if (error) + return ( + + {error.status === 403 + ? 'You do not have permission to list namespaces in this cluster; namespace selection is unavailable.' + : `Unable to load namespaces: ${error.message || 'request failed'}`} + + ); + if (!selected) return ; + return ( + <> + + Matching namespaces visible to your account. Protected namespaces are excluded. This is + current selection; action participants were recorded when the window started. + + ( + + {namespace.metadata.name} + + ), + }, + { + label: 'Status', + getter: (namespace: Namespace) => namespace.status?.phase || 'Unknown', + }, + { + label: 'Labels', + getter: (namespace: Namespace) => ( + + ), + }, + ]} + emptyMessage="No visible namespaces match this window's selectors." + reflectInURL={false} + /> + + ); +} diff --git a/headlamp/src/Overview.tsx b/headlamp/src/Overview.tsx index 0a06179..e867a20 100644 --- a/headlamp/src/Overview.tsx +++ b/headlamp/src/Overview.tsx @@ -3,10 +3,8 @@ import { getCluster } from '@kinvolk/headlamp-plugin/lib/Utils'; import { Alert, Box, - Button, Chip, CircularProgress, - Paper, Stack, Table, TableBody, @@ -20,15 +18,16 @@ import { } from '@mui/material'; import { useMemo, useState } from 'react'; import { + lifecycleStatus, MAX_PREVIEW_ACTIONS, - projectActions, readyStatus, useCurrentProjections, useNow, } from './hooks'; import { ChangeWindow } from './resources'; -import { currentWindow, HORIZON, shiftDays, weekStart } from './schedule'; +import { currentWindow, HORIZON } from './schedule'; import type { ChangeWindowData } from './types'; +import { WindowCalendar } from './WindowCalendar'; export const ROUTE = 'capsule-change-windows'; export const DETAIL_ROUTE = 'capsule-change-window'; @@ -67,7 +66,6 @@ function ClusterOverview({ cluster }: { cluster: string }) { const [items, error] = ChangeWindow.useList({ cluster }); const [query, setQuery] = useState(''); const [page, setPage] = useState(0); - const [week, setWeek] = useState(() => weekStart(new Date())); const now = useNow(); const actions = useMemo( () => @@ -82,18 +80,6 @@ function ClusterOverview({ cluster }: { cluster: string }) { [items, query] ); const current = useCurrentProjections(actions, now); - const calendar = useMemo( - () => projectActions(actions, week.getTime() - HORIZON, shiftDays(week, 7).getTime() + HORIZON), - [actions, week] - ); - const days = Array.from({ length: 7 }, (_, index) => shiftDays(week, index)); - const errors = [ - ...new Set( - [...current, ...calendar] - .filter(item => item.error) - .map(item => `${item.action.metadata.name}: ${item.error}`) - ), - ]; const activeCount = current.filter( item => !item.error && currentWindow(item.windows, now) ).length; @@ -133,132 +119,11 @@ function ClusterOverview({ cluster }: { cluster: string }) { fullWidth sx={{ mb: 2 }} /> - {errors.slice(0, 5).map(message => ( - - {message} - - ))} - {errors.length > 5 && ( - - {errors.length - 5} more windows have unavailable previews. Filter by name or open an - action for details. - - )} - - - - - - - {week.toLocaleDateString(undefined, { dateStyle: 'medium' })} –{' '} - {shiftDays(week, 6).toLocaleDateString(undefined, { dateStyle: 'medium' })} - - - - - {days.map(day => { - const start = day.getTime(); - const end = shiftDays(day, 1).getTime(); - const events = calendar - .flatMap(item => - item.windows - .filter(window => window.start < end && window.end > start) - .map(window => ({ ...item, window })) - ) - .sort( - (a, b) => - a.window.start - b.window.start || - a.action.metadata.name.localeCompare(b.action.metadata.name) - ); - const today = start <= now && now < end; - return ( - - - {day.toLocaleDateString(undefined, { - weekday: 'short', - month: 'short', - day: 'numeric', - })} - - {!events.length && ( - - No windows - - )} - {events.slice(0, 100).map(({ action, window }) => ( - - - - {window.start < start - ? 'Continues' - : new Date(window.start).toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - })}{' '} - –{' '} - {window.end >= end - ? 'next day' - : new Date(window.end).toLocaleTimeString(undefined, { - hour: '2-digit', - minute: '2-digit', - })} - - - ))} - {events.length > 100 && ( - - {events.length - 100} more windows. Filter windows to see them. - - )} - - ); - })} - - - - Calendar entries show scheduled windows. Resource actions depend on controller - readiness, executor permissions, and the resources selected at window start. - - + } + /> {!actions.length ? ( @@ -278,6 +143,7 @@ function ClusterOverview({ cluster }: { cluster: string }) { 'Targets', 'Behavior', 'Controller', + 'Lifecycle', 'Pending', ].map(title => ( {title} @@ -327,6 +193,7 @@ function ClusterOverview({ cluster }: { cluster: string }) { color={ready === 'Ready' ? 'success' : 'default'} /> + {lifecycleStatus(action)} {action.status?.pendingResources || 0} ); diff --git a/headlamp/src/WindowCalendar.tsx b/headlamp/src/WindowCalendar.tsx new file mode 100644 index 0000000..ebf7121 --- /dev/null +++ b/headlamp/src/WindowCalendar.tsx @@ -0,0 +1,347 @@ +import { SectionBox } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import { + Alert, + Box, + Button, + CircularProgress, + Paper, + Stack, + ToggleButton, + ToggleButtonGroup, + Typography, +} from '@mui/material'; +import { type ReactNode, useMemo, useState } from 'react'; +import { + calendarDates, + calendarDays, + calendarLabels, + calendarRange, + type CalendarView, + shiftPeriod, +} from './calendar'; +import { useCalendarProjections } from './hooks'; +import { currentWindow, HORIZON, shiftDays, weekStart } from './schedule'; +import type { ChangeWindowData } from './types'; + +const dateLabel = (date: Date) => date.toLocaleDateString(undefined, { dateStyle: 'full' }); + +export function WindowCalendar({ + actions, + now, + renderAction, +}: { + actions: ChangeWindowData[]; + now: number; + renderAction: (action: ChangeWindowData) => ReactNode; +}) { + const [view, setView] = useState('week'); + const [focus, setFocus] = useState(() => new Date(now)); + const range = useMemo(() => calendarRange(focus, view), [focus, view]); + const projections = useCalendarProjections( + actions, + range.start.getTime() - HORIZON, + range.end.getTime() + HORIZON + ); + const days = useMemo( + () => + calendarDays( + projections || [], + calendarDates(range.start, range.end), + view === 'week' ? 100 : 3 + ), + [projections, range, view] + ); + const byDate = useMemo(() => new Map(days.map(day => [day.date.getTime(), day])), [days]); + const errors = (projections || []).filter(item => item.error); + const openPeriod = (date: Date, nextView: CalendarView) => { + setFocus(date); + setView(nextView); + }; + const title = + view === 'year' + ? String(focus.getFullYear()) + : view === 'month' + ? focus.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }) + : `${range.start.toLocaleDateString(undefined, { dateStyle: 'medium' })} – ${shiftDays( + range.end, + -1 + ).toLocaleDateString(undefined, { dateStyle: 'medium' })}`; + const today = new Date(now).toDateString(); + return ( + + + next && setView(next)} + > + {Object.entries(calendarLabels).map(([key, label]) => ( + + {label} + + ))} + + + + + + {title} + + + {errors.slice(0, 5).map(item => ( + + {item.action.metadata.name}: {item.error} + + ))} + {errors.length > 5 && ( + + {errors.length - 5} more windows have unavailable calendar previews. Filter by name to + narrow the view. + + )} + {!projections ? ( + + + Calculating calendar… + + ) : view === 'year' ? ( + + {Array.from({ length: 12 }, (_, month) => { + const first = new Date(focus.getFullYear(), month, 1); + const label = first.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }); + const gridStart = weekStart(first); + return ( + + + + {Array.from({ length: 7 }, (_, index) => ( + + {shiftDays(gridStart, index).toLocaleDateString(undefined, { + weekday: 'short', + })} + + ))} + {Array.from({ length: 42 }, (_, index) => { + const date = shiftDays(gridStart, index); + if (date.getMonth() !== month) return ; + const day = byDate.get(date.getTime())!; + return ( + + ); + })} + + + ); + })} + + ) : ( + + + {view === 'month' && + days.slice(0, 7).map(({ date }) => ( + + {date.toLocaleDateString(undefined, { weekday: 'short' })} + + ))} + {days.map(({ date, events, count }) => { + const start = date.getTime(); + const end = shiftDays(date, 1).getTime(); + const isToday = date.toDateString() === today; + return ( + + + + {view === 'week' + ? date.toLocaleDateString(undefined, { + weekday: 'short', + day: 'numeric', + }) + : date.getDate()} + + + + {events.map(({ action, window }) => ( + + + {renderAction(action)} + + + {window.start < start + ? 'Continues' + : new Date(window.start).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + })} + {' – '} + {window.end >= end + ? 'next day' + : new Date(window.end).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + })} + + + ))} + {count > events.length && ( + + )} + + + ); + })} + + {!errors.length && !days.some(day => day.count) && ( + + No scheduled windows in this period. + + )} + + )} + + Calendar entries show scheduled windows. Resource actions depend on controller readiness, + executor permissions, and the resources selected at window start. + {view === 'year' && + ' Highlighted days have scheduled windows. Select a month or day to explore them.'} + + + ); +} diff --git a/headlamp/src/WindowEvents.tsx b/headlamp/src/WindowEvents.tsx new file mode 100644 index 0000000..6f657e2 --- /dev/null +++ b/headlamp/src/WindowEvents.tsx @@ -0,0 +1,73 @@ +import { K8s } from '@kinvolk/headlamp-plugin/lib'; +import { DateLabel, SectionBox, SimpleTable } from '@kinvolk/headlamp-plugin/lib/CommonComponents'; +import type Event from '@kinvolk/headlamp-plugin/lib/k8s/event'; +import { Alert, CircularProgress, Typography } from '@mui/material'; +import { useMemo } from 'react'; +import type { ChangeWindow } from './resources'; + +export function WindowEvents({ item, cluster }: { item: ChangeWindow; cluster: string }) { + return ( + + {item.metadata.uid ? ( + + ) : ( + Events are unavailable until the resource has a UID. + )} + + ); +} + +function EventsForUID({ uid, cluster }: { uid: string; cluster: string }) { + // The SDK's ObjectEventList hides fetch errors and matches names without UIDs. + // Keep its standard table presentation, with an identity-scoped shared query. + const [events, error] = K8s.event.default.useList({ + cluster, + namespace: '', + fieldSelector: `involvedObject.uid=${uid}`, + limit: 2000, + refetchInterval: 30000, + }); + const selected = useMemo( + () => + events?.filter(event => event.cluster === cluster && event.involvedObject?.uid === uid) ?? + null, + [events, cluster, uid] + ); + if (error) + return ( + + {error.status === 403 + ? 'You do not have permission to list events for this ChangeWindow.' + : `Unable to load events: ${error.message || 'request failed'}`} + + ); + if (!selected) return ; + return ( + <> + {events && events.length >= 2000 && ( + Showing at most 2,000 events for this ChangeWindow. + )} + event.type || '—' }, + { label: 'Reason', getter: (event: Event) => event.reason || '—' }, + { + label: 'From', + getter: (event: Event) => + event.source?.component || event.jsonData.reportingController || '—', + }, + { label: 'Message', getter: (event: Event) => event.message || '—' }, + { + label: 'Last occurrence', + getter: (event: Event) => , + sort: (event: Event) => -Date.parse(event.lastOccurrence), + }, + ]} + defaultSortingColumn={5} + emptyMessage="No events for this ChangeWindow." + reflectInURL={false} + /> + + ); +} diff --git a/headlamp/src/calendar.test.ts b/headlamp/src/calendar.test.ts new file mode 100644 index 0000000..ea5d082 --- /dev/null +++ b/headlamp/src/calendar.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { calendarDates, calendarDays, calendarRange, shiftPeriod } from './calendar'; +import { projectActions } from './hooks'; +import { DAY, HORIZON, projectWindows } from './schedule'; +import { actionFixture } from './testFixtures'; + +describe('calendar periods', () => { + it('starts weeks on Monday and includes complete weeks in a month', () => { + const week = calendarRange(new Date(2026, 9, 4), 'week'); + expect(week.start).toEqual(new Date(2026, 8, 28)); + expect(week.end).toEqual(new Date(2026, 9, 5)); + const month = calendarRange(new Date(2026, 7, 31), 'month'); + expect(month.start).toEqual(new Date(2026, 6, 27)); + expect(month.end).toEqual(new Date(2026, 8, 7)); + expect(calendarDates(month.start, month.end)).toHaveLength(42); + }); + it('handles leap years and never rolls a short month into the following month', () => { + expect(shiftPeriod(new Date(2028, 0, 31), 'month', 1)).toEqual(new Date(2028, 1, 1)); + expect(shiftPeriod(new Date(2028, 0, 31), 'month', -1)).toEqual(new Date(2027, 11, 1)); + expect(shiftPeriod(new Date(2028, 1, 29), 'year', 1)).toEqual(new Date(2029, 0, 1)); + const year = calendarRange(new Date(2028, 4, 1), 'year'); + const dates = calendarDates(year.start, year.end); + expect(dates).toHaveLength(366); + expect(dates[59]).toEqual(new Date(2028, 1, 29)); + expect(dates[365]).toEqual(new Date(2028, 11, 31)); + }); + it('advances local calendar days over daylight-saving boundaries', () => { + const previous = process.env.TZ; + process.env.TZ = 'Europe/Zurich'; + try { + const dates = calendarDates(new Date(2026, 2, 28), new Date(2026, 2, 31)); + expect(dates.map(date => date.getHours())).toEqual([0, 0, 0]); + expect(dates[2].getTime() - dates[1].getTime()).toBe(23 * 3_600_000); + expect(shiftPeriod(new Date(2026, 9, 19), 'week', 1)).toEqual(new Date(2026, 9, 26)); + } finally { + if (previous === undefined) delete process.env.TZ; + else process.env.TZ = previous; + } + }); +}); + +describe('calendar event buckets', () => { + it('includes carry-over windows and excludes their exact end without mutating inputs', () => { + const dates = calendarDates(new Date(2026, 0, 1), new Date(2026, 0, 4)); + const projections = [ + { + action: actionFixture(), + windows: [ + { start: new Date(2025, 11, 31).getTime(), end: dates[1].getTime() }, + { start: dates[1].getTime() + 1000, end: dates[2].getTime() + 1000 }, + ], + }, + ]; + const before = structuredClone(projections); + const days = calendarDays(projections, dates, 3); + expect(days.map(day => day.count)).toEqual([1, 1, 1]); + expect(days[0].events[0].window.start).toBe(new Date(2025, 11, 31).getTime()); + expect(projections).toEqual(before); + }); + it('bounds rendered entries while counting all windows and handles empty/error projections', () => { + const date = new Date(2026, 9, 1); + const projections = Array.from({ length: 250 }, () => ({ + action: actionFixture(), + windows: [{ start: date.getTime(), end: date.getTime() + 1000 }], + })); + const [day] = calendarDays(projections, [date], 3); + expect(day.count).toBe(250); + expect(day.events).toHaveLength(3); + expect( + calendarDays([{ action: actionFixture(), windows: [], error: 'invalid' }], [date], 3)[0].count + ).toBe(0); + expect(calendarDays([], [], 3)).toEqual([]); + }); + it('retains the earliest events even when policy names sort differently', () => { + const date = new Date(2026, 9, 1); + const projections = [3000, 1000, 2000].map((offset, index) => { + const action = actionFixture(); + action.metadata.name = `policy-${index}`; + return { + action, + windows: [{ start: date.getTime() + offset, end: date.getTime() + offset + 1000 }], + }; + }); + const day = calendarDays(projections, [date], 2)[0]; + expect(day.count).toBe(3); + expect(day.events.map(event => event.action.metadata.name)).toEqual(['policy-1', 'policy-2']); + }); + it('projects leap-day and annual schedules across the full year with carry-in', () => { + const range = calendarRange(new Date(2028, 1, 1), 'year'); + const action = actionFixture(); + action.spec.schedules = [ + { name: 'leap', cron: '0 12 29 2 *', timeZone: 'UTC', duration: '1h' }, + { name: 'new-year', cron: '0 23 31 12 *', timeZone: 'UTC', duration: '2h' }, + ]; + const [projection] = projectActions( + [action], + range.start.getTime() - HORIZON, + range.end.getTime() + HORIZON + ); + expect(projection.error).toBeUndefined(); + expect( + projection.windows.some(window => window.start === Date.parse('2028-02-29T12:00:00Z')) + ).toBe(true); + expect( + projection.windows.some(window => window.start === Date.parse('2027-12-31T23:00:00Z')) + ).toBe(true); + }); + it('retains occurrence and policy caps for the yearly view', () => { + const action = actionFixture(); + action.spec.schedules = [{ name: 'dense', cron: '*/5 * * * *', duration: '1s' }]; + expect(() => projectWindows(action.spec.schedules, 0, 394 * DAY)).toThrow(/too dense/); + action.spec.schedules = [{ name: 'continuous', cron: '* * * * *', duration: '1m' }]; + const result = projectActions( + Array.from({ length: 251 }, () => action), + 0, + 394 * DAY + ); + expect(result[0].windows).toHaveLength(1); + expect(result[250].error).toContain('250'); + expect(() => projectWindows(action.spec.schedules, 0, 401 * DAY)).toThrow(/400 days/); + }); +}); diff --git a/headlamp/src/calendar.ts b/headlamp/src/calendar.ts new file mode 100644 index 0000000..6f66406 --- /dev/null +++ b/headlamp/src/calendar.ts @@ -0,0 +1,88 @@ +import type { Projection } from './hooks'; +import { shiftDays, weekStart } from './schedule'; +import type { ChangeWindowData, WindowInterval } from './types'; + +export type CalendarView = 'week' | 'month' | 'year'; +export const calendarLabels: Record = { + week: 'Weekly', + month: 'Monthly', + year: 'Yearly', +}; + +export function calendarRange(date: Date, view: CalendarView): { start: Date; end: Date } { + if (view === 'week') { + const start = weekStart(date); + return { start, end: shiftDays(start, 7) }; + } + const year = date.getFullYear(); + if (view === 'year') return { start: new Date(year, 0, 1), end: new Date(year + 1, 0, 1) }; + const start = weekStart(new Date(year, date.getMonth(), 1)); + const nextMonth = new Date(year, date.getMonth() + 1, 1); + const end = weekStart(nextMonth); + return { start, end: end < nextMonth ? shiftDays(end, 7) : end }; +} + +export function shiftPeriod(date: Date, view: CalendarView, amount: number): Date { + if (view === 'week') return shiftDays(date, amount * 7); + if (view === 'month') return new Date(date.getFullYear(), date.getMonth() + amount, 1); + return new Date(date.getFullYear() + amount, 0, 1); +} + +export function calendarDates(start: Date, end: Date): Date[] { + const days: Date[] = []; + for (let date = start; date < end; date = shiftDays(date, 1)) days.push(date); + return days; +} + +export interface CalendarEvent { + action: ChangeWindowData; + window: WindowInterval; +} +export interface CalendarDay { + date: Date; + count: number; + events: CalendarEvent[]; +} + +// Projections contain sorted, merged intervals. Advance a cursor per policy so +// a year does not repeatedly scan every occurrence for every day. Retain only the +// visible entries in each cell; counts still include every overlapping interval. +export function calendarDays( + projections: Projection[], + dates: Date[], + visibleLimit: number +): CalendarDay[] { + const cursors = projections.map(() => 0); + return dates.map(date => { + const start = date.getTime(); + const end = shiftDays(date, 1).getTime(); + let count = 0; + const events: CalendarEvent[] = []; + projections.forEach(({ action, windows }, index) => { + while (cursors[index] < windows.length && windows[cursors[index]].end <= start) + cursors[index]++; + for (let i = cursors[index]; i < windows.length && windows[i].start < end; i++) { + count++; + const event = { action, window: windows[i] }; + const compare = (other: CalendarEvent) => + event.window.start - other.window.start || + event.action.metadata.name.localeCompare(other.action.metadata.name); + if ( + visibleLimit <= 0 || + (events.length === visibleLimit && compare(events[events.length - 1]) >= 0) + ) + continue; + let low = 0; + let high = events.length; + while (low < high) { + const middle = (low + high) >>> 1; + if (compare(events[middle]) < 0) high = middle; + else low = middle + 1; + } + events.splice(low, 0, event); + if (events.length > visibleLimit) events.pop(); + } + }); + return { date, count, events }; + }); +} diff --git a/headlamp/src/hooks.test.ts b/headlamp/src/hooks.test.ts new file mode 100644 index 0000000..42cad44 --- /dev/null +++ b/headlamp/src/hooks.test.ts @@ -0,0 +1,66 @@ +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { useCalendarProjections } from './hooks'; +import { DAY } from './schedule'; +import { actionFixture } from './testFixtures'; + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + let elapsed = 0; + vi.spyOn(performance, 'now').mockImplementation(() => (elapsed += 10)); +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +const from = Date.parse('2026-10-01T00:00:00Z'); +const until = from + 365 * DAY; +const actions = [actionFixture(), actionFixture(), actionFixture()]; + +describe('calendar background batches', () => { + it('yields between policies and cancels stale selection work', () => { + const { result, rerender } = renderHook( + ({ selected }) => useCalendarProjections(selected, from, until), + { initialProps: { selected: actions } } + ); + expect(result.current).toBeNull(); + act(() => vi.advanceTimersToNextTimer()); + expect(result.current).toBeNull(); + const selected = [actionFixture()]; + selected[0].metadata.name = 'replacement'; + rerender({ selected }); + act(() => vi.runAllTimers()); + expect(result.current?.map(item => item.action.metadata.name)).toEqual(['replacement']); + expect(result.current?.[0].windows.length).toBeGreaterThan(300); + expect(vi.getTimerCount()).toBe(0); + }); + it('hides previous results when the range changes and cancels on unmount', () => { + const { result, rerender, unmount } = renderHook( + ({ start, end }) => useCalendarProjections(actions, start, end), + { initialProps: { start: from, end: until } } + ); + act(() => vi.runAllTimers()); + expect(result.current).toHaveLength(3); + rerender({ start: from + DAY, end: until + DAY }); + expect(result.current).toBeNull(); + act(() => vi.advanceTimersToNextTimer()); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); + it('keeps the global policy cap across batches and exposes malformed schedules', () => { + const selected = Array.from({ length: 251 }, () => { + const action = actionFixture(); + action.spec.schedules = [{ name: 'continuous', cron: '* * * * *', duration: '1m' }]; + return action; + }); + selected[1].spec.schedules[0].cron = 'invalid'; + const { result } = renderHook(() => useCalendarProjections(selected, from, until)); + act(() => vi.runAllTimers()); + expect(result.current?.[0].windows).toHaveLength(1); + expect(result.current?.[1].error).toContain('Unsupported cron'); + expect(result.current?.[249].windows).toHaveLength(1); + expect(result.current?.[250].error).toContain('250'); + }); +}); diff --git a/headlamp/src/hooks.ts b/headlamp/src/hooks.ts index 0f03624..570f6b3 100644 --- a/headlamp/src/hooks.ts +++ b/headlamp/src/hooks.ts @@ -24,18 +24,63 @@ export function projectActions( from: number, until: number ): Projection[] { - return actions.map((action, index) => { - try { - // Bound browser work even if an administrator installs many policies. - if (index >= MAX_PREVIEW_ACTIONS) - throw new Error( - 'Calendar previews are limited to 250 windows. Filter the list to preview this window.' - ); - return { action, windows: projectWindows(action.spec.schedules, from, until) }; - } catch (error) { - return { action, windows: [], error: error instanceof Error ? error.message : String(error) }; - } - }); + return actions.map((action, index) => projectAction(action, index, from, until)); +} + +function projectAction( + action: ChangeWindowData, + index: number, + from: number, + until: number +): Projection { + try { + if (index >= MAX_PREVIEW_ACTIONS) + throw new Error( + 'Calendar previews are limited to 250 windows. Filter the list to preview this window.' + ); + return { action, windows: projectWindows(action.spec.schedules, from, until) }; + } catch (error) { + return { action, windows: [], error: error instanceof Error ? error.message : String(error) }; + } +} + +// Yield between bounded batches so long calendar ranges remain navigable. Keep +// old results hidden immediately on a selection change and cancel unfinished work. +export function useCalendarProjections( + actions: ChangeWindowData[], + from: number, + until: number +): Projection[] | null { + const [result, setResult] = useState<{ + actions: ChangeWindowData[]; + from: number; + until: number; + items: Projection[]; + } | null>(null); + useEffect(() => { + const items: Projection[] = []; + let timer: ReturnType; + let cancelled = false; + const next = () => { + if (cancelled) return; + const started = performance.now(); + while (items.length < actions.length) { + const index = items.length; + items.push(projectAction(actions[index], index, from, until)); + if (performance.now() - started >= 8) break; + } + if (items.length < actions.length) timer = setTimeout(next, 0); + else setResult({ actions, from, until, items }); + }; + timer = setTimeout(next, 0); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [actions, from, until]); + return result?.actions === actions && result.from === from && result.until === until + ? result.items + : null; } export function useCurrentProjections(actions: ChangeWindowData[], now: number): Projection[] { @@ -48,3 +93,14 @@ export function readyStatus(action: ChangeWindowData): string { if (action.status?.observedGeneration !== action.metadata.generation) return 'Reconciling'; return condition?.status === 'True' ? 'Ready' : condition?.reason || 'Unknown'; } + +export function lifecycleStatus(action: ChangeWindowData): string { + const condition = action.status?.conditions?.find(item => item.type === 'Active'); + if ( + action.status?.observedGeneration !== action.metadata.generation || + (condition?.observedGeneration !== undefined && + condition.observedGeneration !== action.metadata.generation) + ) + return 'Reconciling'; + return condition?.reason || 'Not reported'; +} diff --git a/headlamp/src/matching.test.ts b/headlamp/src/matching.test.ts index 19fec65..f90299e 100644 --- a/headlamp/src/matching.test.ts +++ b/headlamp/src/matching.test.ts @@ -6,7 +6,7 @@ import type { Resource } from './types'; describe('namespace and resource selection', () => { it('ORs namespace selectors and ANDs requirements within each selector', () => { const action = actionFixture(); - action.spec.namespaceSelectors.unshift({ matchLabels: { environment: 'staging' } }); + action.spec.namespaceSelectors!.unshift({ matchLabels: { environment: 'staging' } }); expect(matchingTargets(action, deploymentFixture, namespaceFixture)).toHaveLength(1); expect( matchingTargets( diff --git a/headlamp/src/matching.ts b/headlamp/src/matching.ts index f1d1fa4..659e964 100644 --- a/headlamp/src/matching.ts +++ b/headlamp/src/matching.ts @@ -25,24 +25,33 @@ export function matchesSelector( }); } +/** The same current-label selection is used by detail and maintenance views. */ +export function matchesNamespace( + action: ChangeWindowData, + namespace: Resource, + systemNamespace = DEFAULT_SYSTEM_NAMESPACE +): boolean { + return ( + namespace.kind === 'Namespace' && + ![systemNamespace, 'kube-system', 'kube-public', 'kube-node-lease'].includes( + namespace.metadata.name + ) && + !!action.spec.namespaceSelectors?.some(selector => + matchesSelector(selector, namespace.metadata.labels) + ) + ); +} + export function matchingTargets( action: ChangeWindowData, resource: Resource, namespace: Resource, systemNamespace = DEFAULT_SYSTEM_NAMESPACE ): Target[] { - const name = resource.metadata.namespace; if ( - !name || - namespace.kind !== 'Namespace' || - namespace.metadata.name !== name || - [systemNamespace, 'kube-system', 'kube-public', 'kube-node-lease'].includes(name) - ) - return []; - if ( - !action.spec.namespaceSelectors.some(selector => - matchesSelector(selector, namespace.metadata.labels) - ) + !resource.metadata.namespace || + resource.metadata.namespace !== namespace.metadata.name || + !matchesNamespace(action, namespace, systemNamespace) ) return []; return action.spec.targets.filter( diff --git a/headlamp/src/schedule.bench.ts b/headlamp/src/schedule.bench.ts index f230738..6b46aa2 100644 --- a/headlamp/src/schedule.bench.ts +++ b/headlamp/src/schedule.bench.ts @@ -1,5 +1,8 @@ import { bench, describe } from 'vitest'; -import { projectWindows } from './schedule'; +import { calendarDates, calendarDays, calendarRange, type CalendarView } from './calendar'; +import { projectActions } from './hooks'; +import { HORIZON, projectWindows } from './schedule'; +import { actionFixture } from './testFixtures'; const from = Date.parse('2026-10-01T00:00:00Z'); const until = Date.parse('2026-11-05T00:00:00Z'); @@ -34,3 +37,39 @@ describe('calendar projection', () => { { time: 100, iterations: 5, warmupTime: 0 } ); }); + +describe('calendar views', () => { + for (const view of ['week', 'month', 'year'] as CalendarView[]) { + const range = calendarRange(new Date(2028, 9, 1), view); + const dates = calendarDates(range.start, range.end); + for (const policies of [1, 100]) { + const actions = Array.from({ length: policies }, (_, index) => { + const action = actionFixture(); + action.metadata.name = `window-${index}`; + action.spec.schedules = [ + { + name: 'daily', + cron: `${index % 60} ${10 + Math.floor(index / 60)} * * *`, + duration: '2h', + timeZone: 'Europe/Zurich', + }, + ]; + return action; + }); + bench( + `${view}: ${policies} distinct schedules`, + () => { + const projections = projectActions( + actions, + range.start.getTime() - HORIZON, + range.end.getTime() + HORIZON + ); + const days = calendarDays(projections, dates, view === 'week' ? 100 : 3); + if (projections.some(item => item.error) || days.some(day => day.count !== policies)) + throw new Error('Incomplete preview'); + }, + { time: 100, iterations: 2, warmupTime: 0 } + ); + } + } +}); diff --git a/headlamp/src/schedule.test.ts b/headlamp/src/schedule.test.ts index 312d43f..1c42281 100644 --- a/headlamp/src/schedule.test.ts +++ b/headlamp/src/schedule.test.ts @@ -98,7 +98,7 @@ describe('controller-compatible schedule projections', () => { at([schedule('* * * * *', '1h', 'Mars/Olympus')], '2026-10-01T10:00:00Z') ).toThrow(); expect(() => projectWindows([schedule('* * * * *', '1s')], 0, 20 * DAY)).toThrow(/too dense/); - expect(() => projectWindows([schedule('0 0 * * *')], 0, 100 * DAY)).toThrow(/90 days/); + expect(() => projectWindows([schedule('0 0 * * *')], 0, 401 * DAY)).toThrow(/400 days/); expect(() => projectWindows([], 0, DAY)).toThrow(); }); }); diff --git a/headlamp/src/schedule.ts b/headlamp/src/schedule.ts index 47e74a5..8bd51ec 100644 --- a/headlamp/src/schedule.ts +++ b/headlamp/src/schedule.ts @@ -69,9 +69,9 @@ export function projectWindows( !Number.isFinite(from) || !Number.isFinite(until) || until <= from || - until - from > 90 * DAY + until - from > 400 * DAY ) { - throw new Error('Calendar range must be positive and at most 90 days.'); + throw new Error('Calendar range must be positive and at most 400 days.'); } if (!schedules?.length || schedules.length > 16) throw new Error('Expected 1 to 16 schedules.'); const names = new Set(); diff --git a/headlamp/src/testFixtures.ts b/headlamp/src/testFixtures.ts index 8a995d5..1f3cfcd 100644 --- a/headlamp/src/testFixtures.ts +++ b/headlamp/src/testFixtures.ts @@ -4,7 +4,15 @@ export function actionFixture(): ChangeWindowData { return { apiVersion: 'addons.projectcapsule.dev/v1alpha1', kind: 'ChangeWindow', - metadata: { name: 'development-downtime', uid: 'action-a', generation: 1 }, + metadata: { + name: 'development-downtime', + uid: 'action-a', + generation: 1, + resourceVersion: '1', + creationTimestamp: '2026-10-01T00:00:00Z', + labels: { team: 'platform' }, + annotations: { description: 'Workload maintenance' }, + }, spec: { namespaceSelectors: [{ matchLabels: { environment: 'development' } }], schedules: [{ name: 'daily', cron: '0 10 * * *', timeZone: 'UTC', duration: '2h' }], @@ -28,7 +36,10 @@ export function actionFixture(): ChangeWindowData { }, ], }, - status: { observedGeneration: 1, conditions: [{ type: 'Ready', status: 'True' }] }, + status: { + observedGeneration: 1, + conditions: [{ type: 'Ready', status: 'True', lastTransitionTime: '2026-10-01T10:00:00Z' }], + }, }; } diff --git a/headlamp/src/types.ts b/headlamp/src/types.ts index c947d38..979b207 100644 --- a/headlamp/src/types.ts +++ b/headlamp/src/types.ts @@ -15,6 +15,9 @@ export interface Resource { namespace?: string; uid?: string; generation?: number; + creationTimestamp?: string; + resourceVersion?: string; + annotations?: Record; labels?: Record; ownerReferences?: Array<{ apiVersion: string; @@ -42,7 +45,7 @@ export interface Target { export interface ChangeWindowData extends Resource { spec: { - namespaceSelectors: LabelSelector[]; + namespaceSelectors?: LabelSelector[]; schedules: Schedule[]; targets: Target[]; serviceAccount?: { namespace: string; name: string }; @@ -67,7 +70,14 @@ export interface ChangeWindowData extends Resource { nextTransition?: string; pendingResources?: number; observedGeneration?: number; - conditions?: Array<{ type: string; status: string; reason?: string; message?: string }>; + conditions?: Array<{ + type: string; + status: string; + reason?: string; + message?: string; + observedGeneration?: number; + lastTransitionTime?: string; + }>; }; } diff --git a/headlamp/src/views.test.tsx b/headlamp/src/views.test.tsx index 43906d0..cc0dc61 100644 --- a/headlamp/src/views.test.tsx +++ b/headlamp/src/views.test.tsx @@ -1,5 +1,6 @@ import type { KubeObject } from '@kinvolk/headlamp-plugin/lib/k8s/cluster'; import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Details } from './Details'; import { @@ -7,9 +8,12 @@ import { MAINTENANCE_SECTION, MaintenanceSection, } from './MaintenanceSection'; +import { NamespaceSelection } from './NamespaceSelection'; import { Overview } from './Overview'; +import type { ChangeWindow } from './resources'; import { actionFixture, deploymentFixture, namespaceFixture } from './testFixtures'; import type { ChangeWindowData, Resource } from './types'; +import { WindowEvents } from './WindowEvents'; const api = vi.hoisted(() => ({ cluster: 'cluster-a' as string | null, @@ -18,17 +22,34 @@ const api = vi.hoisted(() => ({ error: null as { status: number; message?: string } | null, namespace: { jsonData: {} as Resource }, namespaceError: null as Error | null, + namespaceList: vi.fn(), + events: vi.fn(), + config: {} as { systemNamespaces?: Record }, now: Date.parse('2026-10-01T11:00:00Z'), request: vi.fn(), })); vi.mock('@kinvolk/headlamp-plugin/lib', () => ({ ApiProxy: { request: api.request }, - K8s: { ResourceClasses: { Namespace: { useGet: () => [api.namespace, api.namespaceError] } } }, + K8s: { + event: { default: { useList: api.events } }, + ResourceClasses: { + Namespace: { useGet: () => [api.namespace, api.namespaceError], useList: api.namespaceList }, + }, + }, })); vi.mock('@kinvolk/headlamp-plugin/lib/Utils', () => ({ getCluster: () => api.cluster })); vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ - SectionBox: ({ title, children }: { title: string; children: React.ReactNode }) => ( -
+ SectionBox: ({ + title, + children, + ...props + }: { + title: string; + children: React.ReactNode; + role?: string; + 'aria-label'?: string; + }) => ( +

{title}

{children}
@@ -37,14 +58,125 @@ vi.mock('@kinvolk/headlamp-plugin/lib/CommonComponents', () => ({ params, children, activeCluster, + routeName, }: { params?: { name: string }; children: React.ReactNode; activeCluster: string; - }) => {children}, + routeName: string; + }) => ( + + {children} + + ), + DateLabel: ({ date }: { date: string }) => {date}, + SimpleTable: ({ + columns, + data, + emptyMessage, + }: { + columns: Array<{ label: string; getter: (value: unknown) => React.ReactNode }>; + data: unknown[]; + emptyMessage: string; + }) => + data?.length ? ( +
+ + + {columns.map(column => ( + + ))} + + + + {data.map((row, index) => ( + + {columns.map(column => ( + + ))} + + ))} + +
{column.label}
{column.getter(row)}
+ ) : ( + {emptyMessage} + ), + Resource: { + DetailsGrid: ({ + cluster, + name, + extraInfo, + extraSections, + actions, + noDefaultActions, + }: { + cluster: string; + name: string; + extraInfo: (item: ChangeWindow) => Array<{ name: string; value: React.ReactNode }>; + extraSections: (item: ChangeWindow) => Array<{ id: string; section: React.ReactNode }>; + actions: (item: ChangeWindow) => React.ReactNode[]; + noDefaultActions: boolean; + }) => { + const data = api.items?.[0]?.jsonData; + if (api.error) return
{api.error.message}
; + if (!data) return Loading resource data; + const item = { jsonData: data, metadata: data.metadata, cluster } as ChangeWindow; + return ( + <> +

{name || data.metadata.name}

+ {actions(item)} + {!noDefaultActions && } + + + + + + + {extraInfo(item).map(row => ( + + + + + ))} + +
Name{data.metadata.name}
{row.name}{row.value}
+ {extraSections(item).map(section => ( +
{section.section}
+ ))} + + ); + }, + ViewButton: () => , + MetadataDictGrid: ({ dict }: { dict: Record }) => ( + + {Object.entries(dict) + .map(([key, value]) => `${key}=${value}`) + .join(', ')} + + ), + ConditionsTable: ({ resource }: { resource: ChangeWindowData }) => ( + + + {resource.status?.conditions?.map(condition => ( + + + + + + ))} + +
{condition.type}{condition.status}{condition.reason}
+ ), + }, +})); +vi.mock('./resources', () => ({ + ChangeWindow: { useList: api.list, useGet: () => [api.items?.[0], api.error] }, })); -vi.mock('./resources', () => ({ ChangeWindow: { useList: api.list } })); -vi.mock('./settings', () => ({ useSettings: () => ({}) })); +vi.mock('./settings', () => ({ useSettings: () => api.config })); vi.mock('./hooks', async importOriginal => ({ ...(await importOriginal()), useNow: () => api.now, @@ -59,6 +191,9 @@ beforeEach(() => { api.namespaceError = null; api.now = Date.parse('2026-10-01T11:00:00Z'); api.request.mockReset(); + api.namespaceList.mockReset().mockReturnValue([[], null]); + api.events.mockReset().mockReturnValue([[], null]); + api.config = {}; }); afterEach(cleanup); @@ -91,9 +226,9 @@ describe('Change Windows overview', () => { expect(within(table).getByRole('link').textContent).toBe('action-000'); expect(api.list).toHaveBeenCalledWith({ cluster: 'cluster-a' }); }); - it('renders a calendar, action table, status, and cluster-aware links', () => { + it('renders a calendar, action table, status, and cluster-aware links', async () => { render(); - expect(screen.getByRole('region', { name: 'Weekly action calendar' })).toBeTruthy(); + expect(await screen.findByRole('region', { name: 'Weekly action calendar' })).toBeTruthy(); const table = screen.getByRole('table', { name: 'Change windows' }); expect(within(table).getByText('Active')).toBeTruthy(); expect(within(table).getByText('Ready')).toBeTruthy(); @@ -101,12 +236,41 @@ describe('Change Windows overview', () => { within(table).getByRole('link', { name: 'development-downtime' }).getAttribute('href') ).toBe('/c/cluster-a/change-windows/development-downtime'); fireEvent.click(screen.getByRole('button', { name: 'Next week' })); - expect(screen.getByRole('region', { name: 'Weekly action calendar' })).toBeTruthy(); + expect(await screen.findByRole('region', { name: 'Weekly action calendar' })).toBeTruthy(); fireEvent.change(screen.getByLabelText('Filter windows by name or resource kind'), { target: { value: 'unknown' }, }); expect(screen.getByText('No actions match this filter.')).toBeTruthy(); }); + it('switches calendar views, navigates years, drills into months and returns to today', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Monthly' })); + expect(await screen.findByRole('region', { name: 'Monthly action calendar' })).toBeTruthy(); + expect(screen.getByTestId('calendar-period').textContent).toBe('October 2026'); + fireEvent.click(screen.getByRole('button', { name: 'Next month' })); + expect(screen.getByTestId('calendar-period').textContent).toBe('November 2026'); + fireEvent.click(screen.getByRole('button', { name: 'Yearly' })); + expect(await screen.findByRole('region', { name: 'Yearly action calendar' })).toBeTruthy(); + expect(screen.getAllByRole('button', { name: /^Open .* 2026$/ })).toHaveLength(12); + fireEvent.click(screen.getByRole('button', { name: 'Next year' })); + expect(screen.getByTestId('calendar-period').textContent).toBe('2027'); + fireEvent.click(await screen.findByRole('button', { name: 'Open February 2027' })); + expect(screen.getByTestId('calendar-period').textContent).toBe('February 2027'); + fireEvent.click(screen.getByRole('button', { name: 'Today' })); + expect(screen.getByTestId('calendar-period').textContent).toBe('October 2026'); + fireEvent.click(screen.getByRole('button', { name: 'Yearly' })); + fireEvent.click( + await screen.findByRole('button', { name: 'Thursday, October 1, 2026: 1 window. Open week' }) + ); + expect(await screen.findByRole('region', { name: 'Weekly action calendar' })).toBeTruthy(); + fireEvent.change(screen.getByLabelText('Filter windows by name or resource kind'), { + target: { value: 'does-not-exist' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Yearly' })); + expect( + await screen.findByRole('button', { name: 'Thursday, October 1, 2026: 0 windows. Open week' }) + ).toBeTruthy(); + }); it.each([403, 404, 500])('shows API errors instead of an empty-success state (%s)', status => { api.error = { status, message: 'test error' }; render(); @@ -128,11 +292,128 @@ describe('Change Windows overview', () => { view.rerender(); expect(within(screen.getByRole('table')).getByText('Inactive')).toBeTruthy(); }); - it('keeps malformed schedules visible with an unknown state', () => { + it('keeps malformed schedules visible with an unknown state', async () => { api.items![0].jsonData.spec.schedules[0].cron = 'broken'; render(); expect(within(screen.getByRole('table')).getByText('Unknown')).toBeTruthy(); - expect(screen.getByRole('alert').textContent).toContain('Unsupported cron'); + expect((await screen.findByRole('alert')).textContent).toContain('Unsupported cron'); + }); +}); + +describe('ChangeWindow detail calendar', () => { + it('previews only the current resource in every view without listing other windows', async () => { + const other = actionFixture(); + other.metadata.name = 'another-window'; + other.metadata.uid = 'another-uid'; + api.items!.push({ jsonData: other }); + render( + +
+ + ); + const week = await screen.findByRole('region', { name: 'Weekly action calendar' }); + expect(within(week).getAllByRole('group')).toHaveLength(7); + expect(within(week).getAllByText('development-downtime')).toHaveLength(7); + expect(within(week).queryByText('another-window')).toBeNull(); + expect( + within(week) + .getByRole('group', { name: 'Thursday, October 1, 2026' }) + .querySelector('[aria-current="date"]') + ).toBeTruthy(); + fireEvent.click(screen.getByRole('button', { name: 'Monthly' })); + const month = await screen.findByRole('region', { name: 'Monthly action calendar' }); + expect(within(month).getAllByText('development-downtime')).toHaveLength(35); + expect(within(month).queryByText('another-window')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Yearly' })); + expect( + await screen.findByRole('button', { + name: 'Thursday, October 1, 2026: 1 window. Open week', + }) + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: /: 2 windows. Open week/ })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Open November 2026' })); + expect(screen.getByTestId('calendar-period').textContent).toBe('November 2026'); + fireEvent.click(screen.getByRole('button', { name: 'Today' })); + expect(screen.getByTestId('calendar-period').textContent).toBe('October 2026'); + expect(api.list).not.toHaveBeenCalled(); + }); + it('shows one empty-period message and refreshes when the resource schedule changes', async () => { + api.items![0].jsonData.spec.schedules[0].cron = '0 10 1 10 *'; + const view = render( + +
+ + ); + await screen.findByRole('region', { name: 'Weekly action calendar' }); + fireEvent.click(screen.getByRole('button', { name: 'Next week' })); + expect(await screen.findByText('No scheduled windows in this period.')).toBeTruthy(); + const week = screen.getByRole('region', { name: 'Weekly action calendar' }); + expect(within(week).queryByText('development-downtime')).toBeNull(); + expect(screen.queryByText('No windows')).toBeNull(); + api.items = [{ jsonData: actionFixture() }]; + view.rerender( + +
+ + ); + const refreshedWeek = await screen.findByRole('region', { name: 'Weekly action calendar' }); + expect(within(refreshedWeek).getAllByText('development-downtime')).toHaveLength(7); + expect(screen.queryByText('No scheduled windows in this period.')).toBeNull(); + }); +}); + +describe('controller lifecycle', () => { + it.each(['Planned', 'Finished'])( + 'shows %s as normal inactive state in overview and details', + reason => { + api.now = Date.parse('2026-10-01T13:00:00Z'); + const action = api.items![0].jsonData; + action.status!.nextTransition = '2026-10-02T10:00:00Z'; + action.status!.conditions!.push({ + type: 'Active', + status: 'False', + reason, + observedGeneration: 1, + message: 'Window is inactive.', + }); + const overview = render(); + expect( + within(screen.getByRole('table', { name: 'Change windows' })).getByText(reason) + ).toBeTruthy(); + overview.unmount(); + render( + +
+ + ); + expect( + within(screen.getByRole('table', { name: 'Metadata' })).getByText(reason) + ).toBeTruthy(); + expect( + within(screen.getByRole('region', { name: 'Conditions' })).getByText(reason) + ).toBeTruthy(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(screen.getByRole('table', { name: 'Metadata' }).textContent).not.toContain( + 'Not reported' + ); + } + ); + it('does not claim a stale lifecycle is current', () => { + const action = api.items![0].jsonData; + action.status!.conditions!.push({ + type: 'Active', + status: 'False', + reason: 'Finished', + observedGeneration: 0, + }); + render( + +
+ + ); + expect( + within(screen.getByRole('table', { name: 'Metadata' })).getByText('Reconciling') + ).toBeTruthy(); }); }); @@ -234,3 +515,183 @@ describe('resource maintenance section', () => { expect(addMaintenanceSection(null, sections)).toBe(sections); }); }); + +describe('standard ChangeWindow details', () => { + it('starts with metadata, then conditions and events, and offers View YAML', () => { + render( + +
+ + ); + expect(screen.getByRole('table', { name: 'Metadata' })).toBe(screen.getAllByRole('table')[0]); + expect(screen.getByRole('button', { name: 'View YAML' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: 'Edit' })).toBeNull(); + expect( + screen + .getAllByRole('heading', { level: 2 }) + .slice(0, 3) + .map(item => item.textContent) + ).toEqual(['Conditions', 'Events', 'Namespace selection']); + expect(screen.queryByText('Resource definition')).toBeNull(); + }); +}); + +describe('namespace selection details', () => { + const namespace = (name: string, labels: Record = {}, cluster = 'cluster-a') => ({ + cluster, + jsonData: { kind: 'Namespace', metadata: { name, labels } }, + metadata: { name, labels }, + status: { phase: 'Active' }, + }); + it('shows only visible matching namespaces, ORs selectors, excludes infrastructure and keeps cluster links', () => { + const action = actionFixture(); + action.spec.namespaceSelectors = [ + { + matchLabels: { environment: 'development' }, + matchExpressions: [{ key: 'team', operator: 'In', values: ['a'] }], + }, + { matchLabels: { environment: 'staging' } }, + ]; + api.config = { systemNamespaces: { 'cluster-a': 'controller-custom' } }; + api.namespaceList.mockReturnValue([ + [ + namespace('selected', { environment: 'development', team: 'a' }), + namespace('staging', { environment: 'staging' }), + namespace('other-team', { environment: 'development', team: 'b' }), + namespace('production', { environment: 'production' }), + namespace('other-cluster', { environment: 'staging' }, 'cluster-b'), + ...['kube-system', 'kube-public', 'kube-node-lease', 'controller-custom'].map(name => + namespace(name, { environment: 'staging' }) + ), + ], + null, + ]); + render(); + expect(screen.getAllByRole('link').map(link => link.textContent)).toEqual([ + 'selected', + 'staging', + ]); + expect(screen.getByRole('link', { name: 'selected' }).getAttribute('href')).toBe( + '/c/cluster-a/namespaces/selected' + ); + expect(api.namespaceList).toHaveBeenCalledWith({ cluster: 'cluster-a' }); + expect(api.request).not.toHaveBeenCalled(); + }); + it('updates with namespace labels, selectors and cluster settings', () => { + const action = actionFixture(); + api.namespaceList.mockReturnValue([ + [namespace('development', { environment: 'development' })], + null, + ]); + const view = render(); + expect(screen.getByRole('link', { name: 'development' })).toBeTruthy(); + api.namespaceList.mockReturnValue([ + [namespace('development', { environment: 'production' })], + null, + ]); + view.rerender(); + expect(screen.queryByRole('link')).toBeNull(); + view.rerender( + + ); + expect(screen.getByRole('link', { name: 'development' })).toBeTruthy(); + api.config = { systemNamespaces: { 'cluster-a': 'development' } }; + view.rerender( + + ); + expect(screen.queryByRole('link')).toBeNull(); + }); + it.each([undefined, []])('does not list namespaces without selectors (%s)', selectors => { + const action = actionFixture(); + action.spec.namespaceSelectors = selectors; + render(); + expect(screen.getByText(/No namespace selectors/)).toBeTruthy(); + expect(api.namespaceList).not.toHaveBeenCalled(); + }); + it('distinguishes loading, no matches and forbidden or failed reads', () => { + const action = actionFixture(); + api.namespaceList.mockReturnValue([null, null]); + const view = render(); + expect(screen.getByLabelText('Loading namespace selection')).toBeTruthy(); + api.namespaceList.mockReturnValue([[], null]); + view.rerender(); + expect(screen.getByText(/No visible namespaces match/)).toBeTruthy(); + for (const status of [403, 500]) { + api.namespaceList.mockReturnValue([ + [namespace('stale', { environment: 'development' })], + { status, message: 'unavailable' }, + ]); + view.rerender(); + expect(screen.getByRole('alert').textContent).toContain( + status === 403 ? 'permission' : 'Unable to load' + ); + expect(screen.queryByText(/No visible namespaces match/)).toBeNull(); + expect(screen.queryByRole('link')).toBeNull(); + } + }); +}); + +describe('ChangeWindow events', () => { + const item = (uid = 'action-a') => ({ metadata: { uid } } as ChangeWindow); + const event = (uid: string, cluster = 'cluster-a') => ({ + cluster, + involvedObject: { uid }, + type: 'Normal', + reason: uid, + message: 'Window reconciled', + source: {}, + jsonData: {}, + lastOccurrence: '2026-10-01T10:00:00Z', + }); + it('uses a bounded shared UID and cluster query, excluding replaced resources', () => { + api.events.mockReturnValue([ + [event('action-a'), event('old-uid'), event('action-a', 'cluster-b')], + null, + ]); + const view = render(); + expect(api.events).toHaveBeenCalledWith({ + cluster: 'cluster-a', + namespace: '', + fieldSelector: 'involvedObject.uid=action-a', + limit: 2000, + refetchInterval: 30000, + }); + expect(screen.getAllByText('Window reconciled')).toHaveLength(1); + expect(screen.queryByText('old-uid')).toBeNull(); + view.rerender(); + expect(api.events).toHaveBeenLastCalledWith( + expect.objectContaining({ + cluster: 'cluster-b', + fieldSelector: 'involvedObject.uid=replacement', + }) + ); + expect(screen.queryByText('Window reconciled')).toBeNull(); + }); + it('distinguishes loading, no events, and errors', () => { + api.events.mockReturnValue([null, null]); + const view = render(); + expect(screen.getByLabelText('Loading events')).toBeTruthy(); + api.events.mockReturnValue([[], null]); + view.rerender(); + expect(screen.getByText('No events for this ChangeWindow.')).toBeTruthy(); + for (const status of [403, 500]) { + api.events.mockReturnValue([[], { status, message: 'unavailable' }]); + view.rerender(); + expect(screen.getByRole('alert').textContent).toContain( + status === 403 ? 'permission' : 'Unable to load' + ); + expect(screen.queryByText('No events for this ChangeWindow.')).toBeNull(); + } + }); + it('never fetches an unscoped event list when identity is missing', () => { + render(); + expect(api.events).not.toHaveBeenCalled(); + expect(screen.getByText(/until the resource has a UID/)).toBeTruthy(); + }); +}); diff --git a/internal/controller/changewindow_controller.go b/internal/controller/changewindow_controller.go index 0185e24..cdadbc7 100644 --- a/internal/controller/changewindow_controller.go +++ b/internal/controller/changewindow_controller.go @@ -8,10 +8,10 @@ import ( "encoding/json" "errors" "fmt" - "reflect" "time" corev1 "k8s.io/api/core/v1" + apiequality "k8s.io/apimachinery/pkg/api/equality" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -89,25 +89,15 @@ func (r *ChangeWindowReconciler) Reconcile(ctx context.Context, req ctrl.Request if err == nil || !action.DeletionTimestamp.IsZero() { pending, err = r.reconcileActions(ctx, action, c, w, now) } - action.Status.ObservedGeneration = action.Generation - action.Status.Active = w.Active && action.DeletionTimestamp.IsZero() - action.Status.WindowStart = asTime(w.Start) - action.Status.WindowEnd = asTime(w.End) + updateStatus(action, w, now, pending, compileErr, err) boundary := w.Start if w.Active { boundary = w.End } - action.Status.NextTransition = asTime(boundary) - action.Status.PendingResources = pending - condition := metav1.Condition{Type: api.ConditionReady, Status: metav1.ConditionTrue, Reason: "Ready", Message: "Schedules and action state are reconciled.", ObservedGeneration: action.Generation} if err != nil { - condition.Status = metav1.ConditionFalse - condition.Reason = "ReconcileError" - condition.Message = "Reconciliation failed; inspect controller logs and executor permissions." ctrl.LoggerFrom(ctx).Error(err, "reconciling ChangeWindow", "name", action.Name) } - meta.SetStatusCondition(&action.Status.Conditions, condition) - if !reflect.DeepEqual(before.Status, action.Status) && action.DeletionTimestamp.IsZero() { + if !apiequality.Semantic.DeepEqual(before.Status, action.Status) && (action.DeletionTimestamp.IsZero() || len(action.Finalizers) > 0) { before.ResourceVersion = action.ResourceVersion if patchErr := r.Status().Patch(ctx, action, client.MergeFromWithOptions(before, client.MergeFromWithOptimisticLock{})); patchErr != nil { return ctrl.Result{}, patchErr diff --git a/internal/controller/changewindow_controller_test.go b/internal/controller/changewindow_controller_test.go index 6e01035..0a08ac8 100644 --- a/internal/controller/changewindow_controller_test.go +++ b/internal/controller/changewindow_controller_test.go @@ -128,10 +128,18 @@ func TestReconcile(t *testing.T) { if err := c.Get(t.Context(), client.ObjectKey{Namespace: "controller", Name: "change-window-test-uid"}, journal); err != nil { t.Fatal("capture not retained", err) } + if err := c.Get(t.Context(), req.NamespacedName, a); err != nil { + t.Fatal(err) + } + assertConditions(t, a, metav1.ConditionFalse, metav1.ConditionFalse, api.ReasonFinishing) f.failEnd = false if _, err := r.Reconcile(t.Context(), req); err != nil { t.Fatal(err) } + if err := c.Get(t.Context(), req.NamespacedName, a); err != nil { + t.Fatal(err) + } + assertConditions(t, a, metav1.ConditionTrue, metav1.ConditionFalse, api.ReasonFinished) if f.ends != 2 || f.lists != 1 || f.starts != 1 { t.Fatalf("unexpected calls %+v", f) } diff --git a/internal/controller/status.go b/internal/controller/status.go new file mode 100644 index 0000000..945d581 --- /dev/null +++ b/internal/controller/status.go @@ -0,0 +1,78 @@ +// Copyright 2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "time" + + capmeta "github.com/projectcapsule/capsule/pkg/api/meta" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + api "github.com/projectcapsule/schedule-action-controller/api/v1alpha1" + "github.com/projectcapsule/schedule-action-controller/internal/schedule" +) + +// updateStatus keeps reconciliation health separate from wall-clock occupancy. +// A completed observed cycle stays Finished until the next cycle starts; future +// boundaries still come from the existing schedule engine, without extra lookups. +func updateStatus(action *api.ChangeWindow, w schedule.Window, now time.Time, pending int32, compileErr, reconcileErr error) { + previous := meta.FindStatusCondition(action.Status.Conditions, api.ConditionActive) + observedWindow := action.Status.Active || previous != nil && (previous.Reason == api.ReasonActive || previous.Reason == api.ReasonFinishing || previous.Reason == api.ReasonFinished) + deleting := !action.DeletionTimestamp.IsZero() + action.Status.ObservedGeneration = action.Generation + action.Status.Active = w.Active && !deleting + action.Status.WindowStart = asTime(w.Start) + action.Status.WindowEnd = asTime(w.End) + boundary := w.Start + if w.Active { + boundary = w.End + } + if deleting { + boundary = time.Time{} + } + action.Status.NextTransition = asTime(boundary) + action.Status.PendingResources = pending + + ready := metav1.Condition(capmeta.NewReadyCondition(action)) + ready.ObservedGeneration = action.Generation + ready.LastTransitionTime = metav1.NewTime(now) + ready.Message = "Schedules and action state are reconciled." + if reconcileErr != nil { + ready.Status = metav1.ConditionFalse + ready.Reason = "ReconcileError" + ready.Message = "Reconciliation failed; inspect controller logs and executor permissions." + } else if deleting { + ready.Status = metav1.ConditionFalse + ready.Reason = capmeta.TerminatingReason + ready.Message = "The ChangeWindow is being deleted." + } + meta.SetStatusCondition(&action.Status.Conditions, ready) + + active := metav1.Condition{ + Type: api.ConditionActive, Status: metav1.ConditionFalse, + Reason: api.ReasonPlanned, Message: "Waiting for the first observed window to start.", + ObservedGeneration: action.Generation, LastTransitionTime: metav1.NewTime(now), + } + switch { + case deleting: + active.Reason, active.Message = api.ReasonFinished, "Window actions have finished during deletion." + if reconcileErr != nil || pending > 0 { + active.Reason, active.Message = api.ReasonFinishing, "Restoring resources before deletion can complete." + } + case compileErr != nil: + active.Status = metav1.ConditionUnknown + active.Reason, active.Message = api.ReasonUnknown, "Window state is unavailable because the policy could not be compiled." + case w.Active: + active.Status = metav1.ConditionTrue + active.Reason, active.Message = api.ReasonActive, "A scheduled window is active." + case pending > 0 || observedWindow && reconcileErr != nil: + active.Reason, active.Message = api.ReasonFinishing, "The window has ended; resource restoration is not complete." + case observedWindow: + active.Reason, active.Message = api.ReasonFinished, "The last observed window has finished; the next start is reported separately." + case w.Start.IsZero(): + active.Reason, active.Message = api.ReasonUnscheduled, "No upcoming schedule occurrence is available." + } + meta.SetStatusCondition(&action.Status.Conditions, active) +} diff --git a/internal/controller/status_bench_test.go b/internal/controller/status_bench_test.go new file mode 100644 index 0000000..93fadb0 --- /dev/null +++ b/internal/controller/status_bench_test.go @@ -0,0 +1,64 @@ +// Copyright 2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "fmt" + "testing" + "time" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + api "github.com/projectcapsule/schedule-action-controller/api/v1alpha1" + "github.com/projectcapsule/schedule-action-controller/internal/policy" + "github.com/projectcapsule/schedule-action-controller/internal/testutil" +) + +func BenchmarkReconcileStatus(b *testing.B) { + for _, size := range []int{1, 100} { + for _, hour := range []int{9, 10, 11} { + b.Run(fmt.Sprintf("policies=%d/hour=%d", size, hour), func(b *testing.B) { + scheme := runtime.NewScheme() + if err := api.AddToScheme(scheme); err != nil { + b.Fatal(err) + } + objects := make([]client.Object, size) + for i := range objects { + a := testutil.Action() + a.Name = fmt.Sprintf("window-%d", i) + objects[i] = a + } + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(&api.ChangeWindow{}).WithObjects(objects...).Build() + mapper := testutil.Mapper() + store := policy.NewStore(mapper) + if err := store.Upsert(objects[0].(*api.ChangeWindow)); err != nil { + b.Fatal(err) + } + now := time.Date(2026, 10, 1, 9, 30, 0, 0, time.UTC) + if hour == 11 { + // Seed an observed active cycle before benchmarking Finished. + now = now.Add(time.Hour) + } + r := &ChangeWindowReconciler{Client: c, Reader: c, Scheme: scheme, Mapper: mapper, Policies: store, Now: func() time.Time { return now }} + req := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(objects[0])} + if _, err := r.Reconcile(b.Context(), req); err != nil { + b.Fatal(err) + } + now = time.Date(2026, 10, 1, hour, 30, 0, 0, time.UTC) + if _, err := r.Reconcile(b.Context(), req); err != nil { + b.Fatal(err) + } + b.ReportAllocs() + for b.Loop() { + if _, err := r.Reconcile(b.Context(), req); err != nil { + b.Fatal(err) + } + } + }) + } + } +} diff --git a/internal/controller/status_test.go b/internal/controller/status_test.go new file mode 100644 index 0000000..1810b6c --- /dev/null +++ b/internal/controller/status_test.go @@ -0,0 +1,230 @@ +// Copyright 2026 Project Capsule Authors +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "errors" + "reflect" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + api "github.com/projectcapsule/schedule-action-controller/api/v1alpha1" + "github.com/projectcapsule/schedule-action-controller/internal/policy" + "github.com/projectcapsule/schedule-action-controller/internal/testutil" +) + +func assertConditions(t *testing.T, action *api.ChangeWindow, ready, active metav1.ConditionStatus, reason string) { + t.Helper() + for kind, want := range map[string]metav1.ConditionStatus{api.ConditionReady: ready, api.ConditionActive: active} { + condition := meta.FindStatusCondition(action.Status.Conditions, kind) + if condition == nil || condition.Status != want || condition.ObservedGeneration != action.Generation || condition.LastTransitionTime.IsZero() || condition.Message == "" { + t.Fatalf("%s condition: %+v; want status=%s generation=%d", kind, condition, want, action.Generation) + } + } + if got := meta.FindStatusCondition(action.Status.Conditions, api.ConditionActive).Reason; got != reason { + t.Fatalf("lifecycle=%s, want %s", got, reason) + } + if action.Status.ObservedGeneration != action.Generation || action.Status.Active != (active == metav1.ConditionTrue) { + t.Fatalf("inconsistent status: %+v", action.Status) + } +} + +func TestReconcileLifecycle(t *testing.T) { + scheme := runtime.NewScheme() + if err := api.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + a := testutil.Action() + a.Status.Conditions = []metav1.Condition{{Type: "External", Status: metav1.ConditionTrue, Reason: "ManagedElsewhere", LastTransitionTime: metav1.Now()}} + writes := 0 + c := fake.NewClientBuilder().WithScheme(scheme).WithStatusSubresource(a).WithObjects(a).WithInterceptorFuncs(interceptor.Funcs{ + SubResourcePatch: func(ctx context.Context, c client.Client, sub string, obj client.Object, p client.Patch, opts ...client.SubResourcePatchOption) error { + writes++ + return c.SubResource(sub).Patch(ctx, obj, p, opts...) + }, + }).Build() + now := time.Date(2026, 10, 1, 9, 59, 59, 0, time.UTC) + mapper := testutil.Mapper() + store := policy.NewStore(mapper) + if err := store.Upsert(a); err != nil { + t.Fatal(err) + } + r := &ChangeWindowReconciler{Client: c, Reader: c, Scheme: scheme, Mapper: mapper, Policies: store, Now: func() time.Time { return now }} + req := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(a)} + for _, tc := range []struct { + at time.Time + reason string + active metav1.ConditionStatus + boundary time.Time + }{ + {now, api.ReasonPlanned, metav1.ConditionFalse, now.Add(time.Second)}, + {now.Add(time.Second), api.ReasonActive, metav1.ConditionTrue, now.Add(time.Hour + time.Second)}, + {now.Add(time.Hour + time.Second), api.ReasonFinished, metav1.ConditionFalse, now.Add(24*time.Hour + time.Second)}, + {now.Add(12 * time.Hour), api.ReasonFinished, metav1.ConditionFalse, now.Add(24*time.Hour + time.Second)}, + {now.Add(24*time.Hour + time.Second), api.ReasonActive, metav1.ConditionTrue, now.Add(25*time.Hour + time.Second)}, + } { + now = tc.at + if _, err := r.Reconcile(t.Context(), req); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), req.NamespacedName, a); err != nil { + t.Fatal(err) + } + assertConditions(t, a, metav1.ConditionTrue, tc.active, tc.reason) + if a.Status.NextTransition == nil || !a.Status.NextTransition.Time.Equal(tc.boundary) { + t.Fatalf("next transition=%v want %v", a.Status.NextTransition, tc.boundary) + } + if !meta.IsStatusConditionTrue(a.Status.Conditions, "External") { + t.Fatal("unrelated condition lost") + } + before := a.Status.DeepCopy() + count := writes + // Restarting the reconciler preserves Finished and must not rewrite stable conditions. + r = &ChangeWindowReconciler{Client: c, Reader: c, Scheme: scheme, Mapper: mapper, Policies: store, Now: func() time.Time { return now }} + if _, err := r.Reconcile(t.Context(), req); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), req.NamespacedName, a); err != nil { + t.Fatal(err) + } + if writes != count || !reflect.DeepEqual(*before, a.Status) { + t.Fatalf("unchanged reconcile wrote status: %d -> %d", count, writes) + } + } +} + +func TestReconcileInvalidPolicyAndRecovery(t *testing.T) { + scheme := runtime.NewScheme() + if err := api.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + a := testutil.Action() + a.Spec.Schedules[0].Cron = "invalid" + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(a).WithStatusSubresource(a).Build() + now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + r := &ChangeWindowReconciler{Client: c, Reader: c, Scheme: scheme, Mapper: testutil.Mapper(), Now: func() time.Time { return now }} + req := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(a)} + if _, err := r.Reconcile(t.Context(), req); err == nil { + t.Fatal("invalid policy accepted") + } + if err := c.Get(t.Context(), req.NamespacedName, a); err != nil { + t.Fatal(err) + } + assertConditions(t, a, metav1.ConditionFalse, metav1.ConditionUnknown, api.ReasonUnknown) + a.Spec.Schedules[0].Cron = "0 10 * * *" + a.Generation++ + if err := c.Update(t.Context(), a); err != nil { + t.Fatal(err) + } + if _, err := r.Reconcile(t.Context(), req); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), req.NamespacedName, a); err != nil { + t.Fatal(err) + } + assertConditions(t, a, metav1.ConditionTrue, metav1.ConditionFalse, api.ReasonPlanned) +} + +func TestReconcileStatusFailures(t *testing.T) { + for _, stage := range []string{"read", "status"} { + t.Run(stage, func(t *testing.T) { + scheme := runtime.NewScheme() + if err := api.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + a := testutil.Action() + failure := errors.New("injected failure") + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(a).WithStatusSubresource(a).WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if stage == "read" { + return failure + } + return c.Get(ctx, key, obj, opts...) + }, + SubResourcePatch: func(context.Context, client.Client, string, client.Object, client.Patch, ...client.SubResourcePatchOption) error { + return failure + }, + }).Build() + r := &ChangeWindowReconciler{Client: c, Reader: c, Scheme: scheme, Mapper: testutil.Mapper()} + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(a)}); !errors.Is(err, failure) { + t.Fatalf("lost %s failure: %v", stage, err) + } + }) + } +} + +func TestReconcileUnscheduledAndContinuous(t *testing.T) { + for _, tc := range []struct { + cron string + active metav1.ConditionStatus + reason string + }{ + {"0 0 30 2 *", metav1.ConditionFalse, api.ReasonUnscheduled}, + {"* * * * *", metav1.ConditionTrue, api.ReasonActive}, + } { + t.Run(tc.reason, func(t *testing.T) { + scheme := runtime.NewScheme() + if err := api.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + a := testutil.Action() + a.Spec.Schedules[0].Cron = tc.cron + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(a).WithStatusSubresource(a).Build() + r := &ChangeWindowReconciler{Client: c, Reader: c, Scheme: scheme, Mapper: testutil.Mapper(), Now: func() time.Time { return time.Date(2026, 10, 1, 10, 30, 0, 0, time.UTC) }} + if _, err := r.Reconcile(t.Context(), ctrl.Request{NamespacedName: client.ObjectKeyFromObject(a)}); err != nil { + t.Fatal(err) + } + if err := c.Get(t.Context(), client.ObjectKeyFromObject(a), a); err != nil { + t.Fatal(err) + } + assertConditions(t, a, metav1.ConditionTrue, tc.active, tc.reason) + if a.Status.NextTransition != nil { + t.Fatalf("invented next transition: %v", a.Status.NextTransition) + } + }) + } +} + +func TestDeletionReportsRestorationFailure(t *testing.T) { + scheme := runtime.NewScheme() + for _, add := range []func(*runtime.Scheme) error{api.AddToScheme, corev1.AddToScheme} { + if err := add(scheme); err != nil { + t.Fatal(err) + } + } + a := testutil.Action() + a.Finalizers = []string{Finalizer} + deleted := metav1.Now() + a.DeletionTimestamp = &deleted + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(a).WithStatusSubresource(a).WithInterceptorFuncs(interceptor.Funcs{ + Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if _, ok := obj.(*corev1.Secret); ok { + return errors.New("journal unavailable") + } + return c.Get(ctx, key, obj, opts...) + }, + }).Build() + r := &ChangeWindowReconciler{Client: c, Reader: c, Scheme: scheme, Mapper: testutil.Mapper(), SystemNamespace: "controller"} + req := ctrl.Request{NamespacedName: client.ObjectKeyFromObject(a)} + if _, err := r.Reconcile(t.Context(), req); err == nil { + t.Fatal("restoration error hidden") + } + if err := c.Get(t.Context(), req.NamespacedName, a); err != nil { + t.Fatal(err) + } + assertConditions(t, a, metav1.ConditionFalse, metav1.ConditionFalse, api.ReasonFinishing) + if len(a.Finalizers) != 1 || a.Status.NextTransition != nil { + t.Fatalf("incorrect deleting state: %+v", a) + } +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 8e804b0..a3d042e 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -5,6 +5,7 @@ package controller import ( "context" + "encoding/json" "fmt" "path/filepath" "reflect" @@ -73,6 +74,48 @@ func TestAPI(t *testing.T) { } }) ctx := t.Context() + t.Run("status conditions and kubectl table", func(t *testing.T) { + window := testutil.Action() + window.ObjectMeta = metav1.ObjectMeta{Name: "status-table"} + if err := c.Create(ctx, window); err != nil { + t.Fatal(err) + } + now := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + r := &ChangeWindowReconciler{Client: c, Reader: c, Scheme: scheme, Mapper: mapper, Now: func() time.Time { return now }} + for _, state := range []string{api.ReasonPlanned, api.ReasonActive, api.ReasonFinished} { + if _, err := r.Reconcile(ctx, ctrl.Request{NamespacedName: client.ObjectKeyFromObject(window)}); err != nil { + t.Fatal(err) + } + if err := c.Get(ctx, client.ObjectKeyFromObject(window), window); err != nil { + t.Fatal(err) + } + active := metav1.ConditionFalse + if state == api.ReasonActive { + active = metav1.ConditionTrue + } + assertConditions(t, window, metav1.ConditionTrue, active, state) + raw, err := disc.RESTClient().Get().AbsPath("/apis/"+api.GroupVersion.String()+"/changewindows/"+window.Name). + SetHeader("Accept", "application/json;as=Table;g=meta.k8s.io;v=v1").DoRaw(ctx) + if err != nil { + t.Fatal(err) + } + table := &metav1.Table{} + if err := json.Unmarshal(raw, table); err != nil { + t.Fatal(err) + } + if len(table.Rows) != 1 { + t.Fatalf("expected one row: %s", raw) + } + cells := map[string]any{} + for i, column := range table.ColumnDefinitions { + cells[column.Name] = table.Rows[0].Cells[i] + } + if cells["Ready"] != "True" || cells["State"] != state || cells["Active"] != string(active) || cells["Next Transition"] != window.Status.NextTransition.UTC().Format(time.RFC3339) { + t.Fatalf("incorrect kubectl columns: %s", raw) + } + now = now.Add(time.Hour) + } + }) t.Run("GVK pattern schema", func(t *testing.T) { for _, version := range []string{"", "*", "apps", "apps/*", "*.example.com/v1*"} { obj := testutil.Action() From 58446a459724de15783c4e73fb8d82442d2ae9fb Mon Sep 17 00:00:00 2001 From: Oliver Baehler Date: Wed, 14 Oct 2026 09:17:19 +0200 Subject: [PATCH 2/2] fix: simplify task file Signed-off-by: Oliver Baehler --- .github/workflows/e2e.yml | 3 +- .github/workflows/lint.yml | 8 +- AGENT.md | 243 +-- CONTRIBUTING.md | 2 +- DEVELOPMENT.md | 75 +- Taskfile.yml | 86 +- {hack => api}/boilerplate.go.txt | 0 devbox.json | 13 +- devbox.lock | 86 + docs/api-reference.yaml | 16 +- docs/content/docs/development/contributing.md | 62 +- docs/content/docs/development/playground.md | 33 +- .../docs/getting-started/installation.md | 2 +- docs/content/docs/reference/api.md | 1522 ++++++++++++++--- docs/data/window_example.yaml | 14 +- docs/hugo.toml | 4 +- docs/tests/home.spec.js | 4 +- e2e/after_hours_test.go | 4 +- e2e/matching_test.go | 2 +- e2e/wildcard_test.go | 2 +- go.mod | 4 +- hack/docs/docs_test.go | 262 --- hack/playground/headlamp-values.yaml | 19 - hack/prek-builtins.toml | 16 - hack/tooling/dev_deploy_test.go | 246 --- hack/tooling/dev_setup_test.go | 228 --- hack/tooling/docs_test.go | 159 -- hack/tooling/headlamp_workflows_test.go | 147 -- hack/tooling/image_test.go | 305 ---- hack/tooling/playground_test.go | 262 --- hack/tooling/release_workflows_test.go | 221 --- hack/tooling/taskfile_test.go | 269 --- hack/tooling/tooling_test.go | 326 ---- hack/tooling/webhook_templates_test.go | 305 ---- hack/tooling/workflows_test.go | 96 -- hack/tools/go.mod | 50 - hack/tools/go.sum | 231 --- internal/admission/wildcard_test.go | 2 +- internal/policy/policy_test.go | 39 + playground/README.md | 52 + .../installation/change-windows/values.yaml | 0 .../installation/headlamp/kustomization.yaml | 4 + .../installation/headlamp/release.flux.yaml | 55 + playground/kind.config.yaml | 12 + .../manifests/demo}/resources.yaml | 0 .../manifests/demo}/window.yaml | 0 .../manifests}/dev/readiness.yaml | 0 .../addons_v1alpha1_changewindow.yaml | 0 .../examples}/after-hours-executor-rbac.yaml | 0 .../manifests/examples}/after-hours.yaml | 0 .../examples}/conditional-maintenance.yaml | 0 .../manifests/examples}/executor-rbac.yaml | 0 .../examples}/freeze-except-pods.yaml | 0 .../manifests/examples}/maintenance-only.yaml | 0 .../examples}/node-pool-maintenance.yaml | 0 .../manifests/examples}/quickstart.yaml | 0 playground/manifests/kustomization.yaml | 7 + prek.toml | 32 +- renovate.json | 75 + 59 files changed, 1993 insertions(+), 3612 deletions(-) rename {hack => api}/boilerplate.go.txt (100%) delete mode 100644 hack/docs/docs_test.go delete mode 100644 hack/playground/headlamp-values.yaml delete mode 100644 hack/prek-builtins.toml delete mode 100644 hack/tooling/dev_deploy_test.go delete mode 100644 hack/tooling/dev_setup_test.go delete mode 100644 hack/tooling/docs_test.go delete mode 100644 hack/tooling/headlamp_workflows_test.go delete mode 100644 hack/tooling/image_test.go delete mode 100644 hack/tooling/playground_test.go delete mode 100644 hack/tooling/release_workflows_test.go delete mode 100644 hack/tooling/taskfile_test.go delete mode 100644 hack/tooling/tooling_test.go delete mode 100644 hack/tooling/webhook_templates_test.go delete mode 100644 hack/tooling/workflows_test.go delete mode 100644 hack/tools/go.mod delete mode 100644 hack/tools/go.sum create mode 100644 playground/README.md rename hack/playground/operator-values.yaml => playground/installation/change-windows/values.yaml (100%) create mode 100644 playground/installation/headlamp/kustomization.yaml create mode 100644 playground/installation/headlamp/release.flux.yaml create mode 100644 playground/kind.config.yaml rename {hack/playground => playground/manifests/demo}/resources.yaml (100%) rename {hack/playground => playground/manifests/demo}/window.yaml (100%) rename {hack => playground/manifests}/dev/readiness.yaml (100%) rename {examples => playground/manifests/examples}/addons_v1alpha1_changewindow.yaml (100%) rename {examples => playground/manifests/examples}/after-hours-executor-rbac.yaml (100%) rename {examples => playground/manifests/examples}/after-hours.yaml (100%) rename {examples => playground/manifests/examples}/conditional-maintenance.yaml (100%) rename {examples => playground/manifests/examples}/executor-rbac.yaml (100%) rename {examples => playground/manifests/examples}/freeze-except-pods.yaml (100%) rename {examples => playground/manifests/examples}/maintenance-only.yaml (100%) rename {examples => playground/manifests/examples}/node-pool-maintenance.yaml (100%) rename {examples => playground/manifests/examples}/quickstart.yaml (100%) create mode 100644 playground/manifests/kustomization.yaml create mode 100644 renovate.json diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ddd4d13..9248b73 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -7,10 +7,9 @@ on: - 'api/**' - 'cmd/**' - 'internal/**' - - 'examples/**' + - 'playground/**' - 'charts/**' - 'e2e/**' - - 'hack/**' - '.ko.yaml' - 'go.*' - 'Taskfile.yml' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5a0c851..dfb451c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: ./.github/actions/setup - - name: Validate tooling and generated artifacts - run: devbox run -- task test:tooling verify-generated - - name: Lint sources, native file checks, YAML, workflows, and shell - run: devbox run -- task lint lint:config lint:files lint:yaml lint:actions lint:shell + - name: Validate generated artifacts + run: devbox run -- task verify-generated + - name: Lint sources, native file checks, YAML, and workflows + run: devbox run -- task lint lint:config lint:files lint:yaml lint:actions diff --git a/AGENT.md b/AGENT.md index 74f8f52..43e6628 100644 --- a/AGENT.md +++ b/AGENT.md @@ -1,15 +1,93 @@ -# Scheduled Action Controller +# Change Windows ## Purpose -This repository is a dedicated Capsule addon for time-based Kubernetes resource -behavior. Its cluster-scoped `ChangeWindow` API uses the group -`addons.projectcapsule.dev/v1alpha1`. It combines maintenance admission rules, -advance warnings, and scheduled server-side apply actions such as downscaling -Deployments and restoring their original replica counts. It is independent of -Capsule core and does not require Tenant objects. - -## API and behavior +Change Windows is a standalone Project Capsule addon for time-based Kubernetes +operations. Its cluster-scoped `ChangeWindow` API uses +`addons.projectcapsule.dev/v1alpha1` for maintenance admission rules, advance +warnings, and scheduled actions such as downscaling and restoring workloads. +It works with or without Capsule and never requires Tenant objects. + +## General repository rules + +These rules are language-independent and can be reused in other repositories. + +- Read the contribution/development guides, task definitions, dependency locks, + hook configuration, and CI workflows before editing. Preserve unrelated work. +- Reuse maintained upstream tools and existing repository implementations before + adding dependencies, wrappers, or frameworks. Keep task commands direct and + inline; avoid helper tasks that only dispatch to other tasks or shell scripts. +- Keep development installations and executable examples together in `playground/`, + with separate `installation/` and `manifests/` directories. Put generator inputs + beside the owning code. Do not reintroduce `hack/` or a root `examples/` tree, + or accumulate loose Go/JS maintenance scripts. Prefer upstream CLIs over wrappers. +- Prefer predefined prek hooks, especially native builtins, over local hooks. + Use local hooks only for project-specific composition or the shared tool + environment; those should call existing Task targets through Devbox. Keep the + configuration in `prek.toml`, without a second hook configuration or recursion. + Hooks and CI must not require Python. +- Keep tool versions in Devbox configuration and locks. Tasks consume tools from + PATH; avoid repeated version checks, bespoke installers, and fallback toolchains. + For a tool absent from the package index, provision a pinned upstream CLI in + Devbox's ignored environment, without adding a wrapper or separate module. +- Use tools' native validation, linting, and dry runs for configuration changes. + Do not add loose Go or JS tests that assert Taskfile text, dependency versions, + workflow strings, or repository layout. Tests must exercise product behavior + and live alongside the owning code or in its existing integration/browser suite. +- CI calls the same Task targets as local development. Preserve ordered generation + before consumers; Task dependencies run concurrently, `cmds` task calls do not. + Quote configurable values, propagate failures, and clean up temporary files and + child processes. Keep publishing explicit and separate from local builds. +- Run the checks appropriate to the change and report unrun or unavailable checks. + Review security, performance, and bounded resource use. Do not create artificial + test harnesses merely to claim coverage of documentation or tooling changes. +- Never reuse or delete unrelated clusters or credentials. Scope development + operations to an explicitly selected context. Do not commit or publish unless + requested; preview release commands with a dry run first. + +## Go codebase rules + +These rules apply to Go implementation and tests, not to repository maintenance. + +- Follow `go.mod`, `.golangci.yml`, neighboring package conventions, and the + Apache-2.0 source header. Keep domain logic out of entry points. Format tests too. +- Pass context through API operations, preserve useful errors, and bound retries. + Controllers must reconcile idempotently across retries and leader changes. +- Keep shared compiled objects immutable and synchronized. Define invalidation + for spec/UID changes and deletion. Reuse indexed lookups and existing caches; + avoid repeated compilation, cluster-wide scans, and unnecessary API reads. +- Protect object identity with UID checks and optimistic concurrency. Surface SSA + conflicts instead of silently forcing ownership. Use finite metric labels, + never object names, namespaces, identities, or error text. +- Place unit tests beside the implementation they exercise. Test observable + behavior, relevant errors, and boundaries. Use real API-server tests for SSA and + schema behavior and scoped e2e tests for runtime, admission, and RBAC changes. +- Test concurrent access with `-race`. Add allocation-reporting benchmarks for + new or materially changed scheduling, admission, and cache paths. Compilation + alone does not establish an integration or e2e pass. +- Generate APIs and manifests with the existing tasks; never hand-edit generated + DeepCopy methods, CRDs, or RBAC. Preserve Kubebuilder metadata and scaffold markers. + +## Change Windows project rules + +The following sections are specific to this addon. + +### Prefer Capsule imports + +**Always prefer importing and reusing code from `github.com/projectcapsule/capsule` +whenever a suitable implementation is available.** Before implementing functionality, +search Capsule core for existing packages, API types, interfaces, validators, +matchers, templating, and helpers. Direct imports take precedence over copying code, +reimplementing behavior locally, or introducing another dependency for the same job. +The existing `pkg/api/runtime.VersionKind` import is an example of this approach. +If no suitable implementation exists in Capsule, consider suggesting a feature to the core, if suitable. Ask for feedback from the managing entity. + +Preserve Capsule's established semantics and use a compatible, pinned module version. +A local alternative requires a documented, concrete reason why the available Capsule +implementation cannot be imported or reused. Importing Capsule code must preserve +this addon's ability to run without a Capsule installation or Tenant objects. + +### API and behavior - Namespace selectors form an OR; each selector retains Kubernetes AND semantics. An explicit `[{}]` selects all namespaces except protected infrastructure namespaces. @@ -46,7 +124,7 @@ Capsule core and does not require Tenant objects. Admission uses wall-clock schedules, not potentially stale status. Dry-run requests receive the same decisions and warnings without side effects. -## Development map +### Development map - `api/v1alpha1`: API types and validation markers; generated DeepCopy code. - `internal/schedule`: bounded cron-window evaluation with cached boundaries. @@ -56,25 +134,13 @@ Capsule core and does not require Tenant objects. - `internal/admission`: policy validation, matching, denials, warnings and cache wiring. - `internal/metrics`: Prometheus metrics registered with controller-runtime. - `cmd/main.go`: manager, dependencies, TLS, metrics and webhook registration. -- `examples`: executable ChangeWindow and executor RBAC examples. +- `playground/manifests`: demo resources, executable ChangeWindow/RBAC examples, and development probes. - `charts/change-windows`: Helm chart, generated CRDs/RBAC, and enterprise releases. -- `e2e`, `hack`: isolated Kind testing and build/release tooling. +- `e2e`: isolated Kind integration tests. +- `playground/installation`: Helm values and the Flux HelmRelease for Headlamp; `playground/kind.config.yaml` configures Kind. - `docs`: Hugo/Doks site, feature guides and generated API reference. - `headlamp`: read-only Change Windows UI, resource maintenance sections and browser tests. -Read `CONTRIBUTING.md`, `DEVELOPMENT.md`, `Taskfile.yml`, `devbox.json`, `devbox.lock`, -`prek.toml`, `go.mod`, `.golangci.yml`, -and `.github/workflows` before editing. Preserve the Kubebuilder `PROJECT` and -scaffold markers. Reuse existing packages rather than adding parallel frameworks. - -Before adding code, inspect the main `github.com/projectcapsule/capsule` project -for reusable packages, API types, and helpers. Reuse them whenever possible instead -of duplicating their implementation, as with the existing -`pkg/api/runtime.VersionKind` matcher. Preserve Capsule's established semantics and -use a compatible, pinned module version. If direct reuse is unsuitable, document -the reason before introducing a local alternative. Reusing Capsule code must keep -this addon usable without a running Capsule installation or Tenant objects. - Helm is the only supported installation method. Keep user-facing installation, operations, and executor RBAC guidance aligned with the Helm chart's release namespace and service accounts. Generate CRDs and RBAC directly into the chart; @@ -87,80 +153,59 @@ builds or duplicate build/test commands in workflows. GitHub Actions should call Task targets with the shared locked Devbox setup; publishing and signing stay explicit and restricted to release jobs. -## Correctness, security and scale - -- Pass context through API operations, preserve useful errors, and use bounded - retries. Reconciliation must be idempotent and survive leader changes. -- Journal data is sensitive: store it only in controller-namespace Secrets, never - status, metrics, events or logs. Keep journal size and participant counts bounded. -- Executor service accounts require explicit, narrowly scoped RBAC. Do not grant - arbitrary service-account impersonation, workload write access, or cluster-admin - by default. Only the authenticated executor marker bypasses its own policy; - other ChangeWindows must still apply. -- Protect object identity with UID and optimistic concurrency. Surface SSA ownership - conflicts rather than silently enabling force. Do not release a restoration - finalizer while required end actions remain unsuccessful. -- Keep shared compiled objects immutable and synchronized. Invalidate on spec/UID - changes and deletion. Admission should use indexed candidates, compile nothing - per request, and avoid API reads on irrelevant operations/resources. -- Use finite metric labels, never object names, namespaces, identities or error text. - Review every change for security, bounded work, API round trips and memory growth. - -## Required checks +### Execution and security + +Journal data is sensitive: store it only in controller-namespace Secrets, never +status, metrics, events, or logs. Bound journal size and participant counts. +Preserve originals across retries; keep restoration finalizers until required +end actions succeed. + +Executor service accounts require explicit, narrowly scoped RBAC. Do not grant +arbitrary impersonation, workload write access, or cluster-admin by default. +Only the authenticated executor marker bypasses its own policy; other windows +must still apply. Admission must skip irrelevant operations before API reads. + +### Development and validation Enter `devbox shell`, run `task fmt generate manifests`, then `task check`. -Never hand-edit generated CRDs, RBAC or DeepCopy methods. `task manifests` also -refreshes chart CRDs and manager RBAC. The exact development tools belong in -`devbox.json` and `devbox.lock`; tasks consume tools from PATH, without downloading -tool binaries into the repository. Keep hooks and CI free of Python tooling: -use prek's native builtins, yamlfmt, ShellCheck, actionlint and Cocogitto. -Use `task test:tooling` for Task/hook/release-helper changes. Preserve serialized -generation before consumers; Task dependencies run concurrently, while `cmds` -task calls are ordered. Pass configurable values via quoted environment variables. -Keep task implementations inline in `Taskfile.yml`; do not add shell wrappers -under `hack/`. Keep concrete targets and avoid mode-dispatch helper tasks. -GitHub-specific output formatting belongs in workflows. All prek hooks must call Task targets through Devbox. Native prek -checks belong in `hack/prek-builtins.toml`, which must not call Task recursively. -Keep inline commands covered by `task lint:shell` and behavior tests. Preserve -signal handling, temporary-file cleanup, and explicit publishing boundaries. -For documentation/API/sample changes, run `task docs:generate docs:check`. -Keep `docs/content/docs/reference/api.md` generated from the Go types. Share executable -examples through the Hugo mounts of `examples/`. The documentation generator -is a pinned Go tool in `hack/tools/`; its dependencies stay out of the controller -module. Doks and its Sass dependencies are pinned in docs/package-lock.json; -documentation tasks install them with npm ci --ignore-scripts. Keep docs/go.mod -as a module boundary so Go tools do not traverse npm dependencies. Use relref -links between documentation pages and verify root and subpath builds. -Keep the default description cascade in docs/hugo.toml and add page-specific -descriptions where useful. This keeps SEO, Open Graph, and Twitter metadata from -invoking Hugo's automatic Summary, which can panic during live content rebuilds. -Check incremental preview updates as well as clean builds when changing this path. -The homepage YAML explorer uses examples/quickstart.yaml and annotations in -docs/data/window_example.yaml. Preserve exact copy/download content and native -links without JavaScript. For interaction changes, install Playwright Chromium -and run task docs:test:browser; CI runs it on desktop and mobile viewports. +Use native validators and Task dry runs for tooling changes. `task lint:files` +selects the builtin `files` hook group; project hooks must not join that group. +Actionlint also checks workflow shell commands with ShellCheck. + +Unit tests cover executable examples through the policy compiler. For scheduling, +admission, and RBAC changes, cover allowed/denied requests, selected/unselected +namespaces, multiple policies, dry runs, subresources, retries, deletion and +replacement, conflicts, and capture retention where relevant. + +`task e2e` builds current source and creates its own Kind cluster with a private +kubeconfig. Preserve logs before cleanup, observe the run, then diagnose failures. +`dev:setup` and `dev:deploy` mutate only the explicit development context; +`dev:deploy` builds with ko and installs through Helm. `enterprise:release` and +`enterprise:prerelease` publish; inspect their dry runs before release work. + +### Documentation and Headlamp + +Run `task docs:generate docs:check` for API/documentation changes. The pinned +upstream `fybrik.io/crdoc` CLI generates `docs/content/docs/reference/api.md` +directly from Helm CRDs. Use its native frontmatter template and TOC configuration; +do not add a Go wrapper, separate tool module, or output-rewriting script. +Devbox provisions crdoc; `docs:check` checks drift, builds root/subpath sites, and +validates local links and fragments with lychee. + +Keep Doks/Sass locked in `docs/package-lock.json`; use npm ci --ignore-scripts. +Keep `docs/go.mod` as a boundary excluding npm dependencies from Go tooling. +Share executable examples through Hugo mounts and use relref between pages. +Preserve the default description cascade in `docs/hugo.toml` to avoid Hugo's +automatic Summary panic on live rebuilds. Check incremental previews when changing it. + +The homepage YAML explorer uses `playground/manifests/examples/quickstart.yaml` with annotations in +`docs/data/window_example.yaml`. Preserve exact copy/download content and native +links without JavaScript. Run `task docs:test:browser` for interaction changes; +tests live under `docs/tests/` and cover desktop and mobile viewports. + For plugin changes, read `headlamp/README.md` and run `task headlamp:check`. -Use Headlamp's public APIs and shared React/MUI libraries. Preserve cluster scope, -Kubernetes selector semantics, owner UID checks, bounded projections and explicit -unavailable/forbidden states. Never infer completed actions from current labels -or read private capture journals. Browser tests use an existing Headlamp frontend -with intercepted API fixtures, and never create credentials or cluster resources. - -Add meaningful unit tests for changed behavior, real API-server tests for SSA and -schema behavior, and scoped e2e coverage for scheduling/admission/RBAC changes. -Cover allowed and denied requests, selected and unselected namespaces, different -policies, dry runs, subresources, restart/retry, deletion/replacement, conflicts, -and capture retention where relevant. Test concurrent cache access with `-race`. -Add allocation-reporting benchmarks for new scheduling/admission/cache paths. -Report unavailable or unrun checks explicitly; compilation is not an e2e pass. - -`task e2e` builds the current controller and creates its own Kind cluster with a -private kubeconfig. Never reuse/delete the developer's existing clusters. Preserve -logs before cleanup. Observe runs first, then diagnose and fix after they finish. - -Use conventional commits and the Apache-2.0 Go header. Do not commit or publish -unless requested. `enterprise:release` and `enterprise:prerelease` publish images -and Helm charts; inspect a dry run first. Enterprise release dry runs print the -publication and deployment commands. `dev:setup` and `dev:deploy` apply resources -to the explicitly selected development context; `dev:deploy` builds current source -with ko and installs through Helm. `task e2e` exercises that path in its own disposable cluster. +Use public Headlamp APIs and shared React/MUI libraries. Preserve cluster scope, +selector semantics, owner UID checks, bounded projections, and explicit forbidden +or unavailable states. Never infer completed actions from current labels or read +private capture journals. Browser tests belong in `headlamp/e2e/`, use intercepted +API fixtures, and never create credentials or cluster resources. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bc4c99a..4e3773a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ 6. Use Conventional Commit messages, for example `feat: add schedule validation`. Go files use the Project Capsule copyright and Apache-2.0 SPDX header from -`hack/boilerplate.go.txt`. Never edit generated DeepCopy methods or CRDs by hand. +`api/boilerplate.go.txt`. Never edit generated DeepCopy methods or CRDs by hand. See [DEVELOPMENT.md](DEVELOPMENT.md) for tooling, layout, and runtime behavior. Install Git hooks with `devbox run -- task hooks:install`. Hooks use prek and diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 94d9177..167e9e2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -44,10 +44,10 @@ is also pinned. Review version changes and rerun `task check`. - `api/v1alpha1/`: cluster-scoped API types and generated DeepCopy methods. - `cmd/main.go`: Kubebuilder manager entry point and scheme registration. - `internal/`: controller, admission, scheduling, execution and metrics code. -- `examples/`: executable ChangeWindow and executor RBAC examples. +- `playground/manifests/examples/`: executable ChangeWindow and executor RBAC examples. - `charts/`: Helm packaging, including generated CRDs and RBAC. - `e2e/`: tests against the built controller in an isolated Kind cluster. -- `hack/`: development tooling tests, native check configuration, and API-reference tooling. +- `playground/`: Kind configuration, Flux installation, and demo/example manifests. - `Taskfile.yml`: inline developer and CI tasks; `devbox.json`/`devbox.lock`: tools. - `prek.toml`: native file checks and local development/commit-message hooks. @@ -57,7 +57,7 @@ as the maintained entry point when incorporating that scaffold. Helm is the only supported installation method; see the [installation guide](docs/content/docs/getting-started/installation.md). -CRDs and RBAC are generated directly into the chart; examples live in `examples/`. +CRDs and RBAC are generated directly into the chart; examples live in `playground/manifests/examples/`. ## Run the controller on your host @@ -167,32 +167,30 @@ generated files. It assumes intended source/generated files have been added to Git. Generation task calls are serialized and deduplicated within a Task run. `task fmt` formats Go (including tests) and maintained YAML. `task lint`, -`task lint:yaml`, `task lint:actions`, `task lint:shell`, and `task helm:lint` match +`task lint:yaml`, `task lint:actions`, and `task helm:lint` match CI checks. YAML validation uses prek's Rust-native parser; yamlfmt checks style. Generated CRDs/RBAC retain their generator's formatting, and Helm templates are validated through Helm instead of being parsed as ordinary YAML. Actionlint uses ShellCheck for workflow commands; its optional Python checker is disabled. Task implementations live directly in `Taskfile.yml`, including image builds, releases, documentation, development deployment, and e2e cleanup. -`task lint:shell` checks those inline commands with ShellCheck. Tasks that need -Bash signal handling or timers use an inline Bash block. +Tasks that need Bash signal handling or timers use an inline Bash block. `task hooks:install` replaces pre-commit's existing `pre-commit` and `commit-msg` Git hook shims with prek, without chaining the old Python hooks. -Every hook calls a Task target through `devbox run -- task`, including file -hygiene, YAML formatting, and Conventional Commit validation. The same targets -can be run directly in a Devbox shell. `task lint:files` invokes prek's native -checks through `hack/prek-builtins.toml`; that configuration contains no Task -hooks, avoiding recursion. File hygiene checks report changes without modifying -files; fix reported issues and stage them before retrying. No Python environments -or pip dependencies are used. `task hooks:check` runs all pre-commit hooks on tracked files; new files must -be staged to participate. The generator hook runs before checks that consume its -output. - -`task test:tooling` exercises Task variables, failure propagation, native syntax -and commit checks, Helm rendering, and enterprise dry runs without publishing -or accessing a cluster. The legacy hyphenated command names remain Task aliases -where useful, for example `task test-unit` and `task enterprise-release`. +Prefer predefined prek hooks: native file hygiene and syntax checks live directly +in `prek.toml`. Project-specific generation, build, formatting, and commit hooks +call Task targets through `devbox run -- task`. `task lint:files` selects only the +builtin `files` group, avoiding recursive calls. File hygiene checks report issues +without modifying files. Fix and stage changes before retrying. No Python +environments or pip dependencies are used. + +`task hooks:check` runs all pre-commit hooks on tracked files; new files must be +staged to participate. Generation runs before checks that consume its output. +Validate tooling with native linters, Task dry runs, and the affected commands. +Keep product tests beside their code; do not add standalone Go/JS harnesses to +assert configuration text, versions, or repository layout. Legacy hyphenated +commands remain aliases where useful, such as `task enterprise-release`. ## Runtime and validation @@ -253,25 +251,31 @@ then run the documentation checks and inspect the rendered site. Edit weighted Markdown pages under `docs/content/docs/`. Group indexes define the sidebar. Use Hugo `relref` links between pages so navigation also works on project sites. The shared `example` shortcode and download mounts read directly -from `examples/`; do not duplicate executable examples. +from `playground/manifests/examples/`; do not duplicate executable examples. -The homepage explorer renders `examples/quickstart.yaml` without changing +The homepage explorer renders `playground/manifests/examples/quickstart.yaml` without changing its content. Annotations in `docs/data/window_example.yaml` connect each top-level spec block to its explanation, guide, and API type. Keep those links accurate as -the API evolves. The static checks verify that copyable YAML and the download +the API evolves. The browser tests verify that copyable YAML and the download exactly match the executable sample. -`docs/content/docs/reference/api.md` is generated by the pinned `crd-ref-docs` -Go tool in `hack/tools/`. Run `task docs:generate` after changing API types. -The generator's dependencies stay outside the controller module. The empty -`docs/go.mod` also prevents Go tools from traversing npm dependencies. +`docs/content/docs/reference/api.md` is generated from the Helm CRDs by +[crdoc](https://github.com/fybrik/crdoc). Run `task docs:generate` after changing API +types; it refreshes chart manifests first. `docs/api-reference.yaml` supplies the +title and API selection to crdoc's native frontmatter template. -`task docs:check` checks generated-reference drift, validates examples, and -builds root and project-site variants. It verifies every page's local links, -anchors, assets, search index, and downloadable examples. For theme changes, -also check search, code copying, dark mode, and mobile navigation in a browser. -The Sass partial suppresses only known deprecations in Doks/Bootstrap; other -build warnings still fail validation. +crdoc is not currently indexed by Devbox. The Devbox initialization hook installs +the pinned `fybrik.io/crdoc` CLI into `.devbox/crdoc//bin` on first use +and adds it to PATH. Change `CRDOC_VERSION` in `devbox.json` to upgrade it. This +needs no wrapper, extra Go module, or controller dependency. The empty +`docs/go.mod` keeps npm dependencies outside controller Go tooling. + +`task docs:check` checks generated-reference drift, builds root and project-site +variants, and uses lychee to check local links, fragments, and assets offline. +External website availability does not affect this check. Policy unit tests +validate the executable ChangeWindow examples. For theme changes, also run the +browser tests for search, copying, dark mode, and mobile navigation. Known +Doks/Bootstrap Sass deprecations are suppressed; other build warnings fail. For interactive documentation changes, install Chromium once and run the browser suite. It covers desktop/mobile, keyboard selection, copying, dark mode, and the @@ -444,12 +448,13 @@ public service. See [Scorecard setup and private repository permissions](https:/ ## Temporary playground `devbox run -- task playground` creates a fresh Kind cluster with the current ko -controller build, Headlamp, the current plugin, and a cycling downscale example. +controller build, Flux-managed Headlamp, the current plugin, and a cycling downscale example. It serves Headlamp at `http://127.0.0.1:18081` for 30 minutes after readiness, then removes the cluster, private kubeconfig, port-forward and staged plugin. Ctrl+C also cleans up. `PLAYGROUND_MINUTES` accepts 1–30; `PLAYGROUND_PORT` and `PLAYGROUND_CLUSTER` choose a free port and a new cluster name. Existing clusters and the developer's current context are preserved. -See the [playground guide](docs/content/docs/development/playground.md) for the +See [playground/README.md](playground/README.md) for the installation/manifests +layout and the [playground guide](docs/content/docs/development/playground.md) for the demo identity's permissions and an example maintenance cycle. diff --git a/Taskfile.yml b/Taskfile.yml index 979aa5b..9df4c1a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -48,7 +48,7 @@ tasks: desc: Generate DeepCopy methods. run: once cmds: - - controller-gen object:headerFile=hack/boilerplate.go.txt paths=./... + - controller-gen object:headerFile=api/boilerplate.go.txt paths=./... manifests: desc: Generate CRDs and RBAC directly into the Helm chart. @@ -96,42 +96,11 @@ tasks: read -r -a flags <<< "$GO_TEST_FLAGS" go test "${flags[@]}" ./... -skip '^TestAPI$' - test:tooling: - desc: Test Task, hook, rendering, and release command behavior without publishing. - cmds: - - go test -tags=tooling ./hack/tooling -v - docs:generate: - desc: Generate the API reference from Go types. + desc: Generate the API reference from Helm CRDs using crdoc. cmds: - - | - root="$PWD" - scratch="$(mktemp -d)" - trap 'rm -rf "$scratch"' EXIT - reference="${REFERENCE_PATH:-$root/docs/content/docs/reference/api.md}" - - # Keep the generator's dependencies out of the controller module. Go caches the - # pinned tool; no downloaded executable or Python environment is maintained here. - go -C "$root/hack/tools" tool crd-ref-docs \ - --source-path="$root/api" \ - --config="$root/docs/api-reference.yaml" \ - --renderer=markdown --output-path="$scratch/reference.md" - { - cat <<'FRONTMATTER' - --- - title: API reference - weight: 10 - description: Generated reference for the ChangeWindow Kubernetes API. - --- - - - - FRONTMATTER - # Make API types directly navigable in the page table of contents. - sed '/^# API Reference$/d; s/^#### /## /; s/[[:blank:]]*$//' "$scratch/reference.md" | - awk 'NF { if (blank && seen) print ""; print; blank=0; seen=1; next } { blank=1 }' - } > "$scratch/page.md" - cp "$scratch/page.md" "$reference" + - task: manifests + - crdoc --resources charts/change-windows/crds --template frontmatter.tmpl --toc docs/api-reference.yaml --output "${REFERENCE_PATH:-docs/content/docs/reference/api.md}" docs:install: desc: Install locked Doks theme and asset dependencies. @@ -157,7 +126,7 @@ tasks: - npm --prefix docs run dev -- --baseURL http://localhost:1313/ docs:check: - desc: Check API-reference drift, example validity, and rendered links/assets. + desc: Check API-reference drift and rendered links for root and subpath sites. cmds: - task: docs:install - | @@ -168,7 +137,15 @@ tasks: echo 'API reference is stale. Run task docs:generate.' >&2 exit 1 } - - go test -tags=docs ./hack/docs -v -count=1 + - | + scratch=$(mktemp -d) + trap 'rm -rf "$scratch"' EXIT + for prefix in '' schedule-actions; do + site="$scratch/${prefix:-root}" + npm --prefix docs run build -- --baseURL "https://change-windows.addons.projectcapsule.dev/${prefix:+$prefix/}" --destination "$site/$prefix" + lychee --offline --include-fragments --index-files index.html --no-progress --root-dir "$site" \ + --remap "^https://change-windows[.]addons[.]projectcapsule[.]dev/(.*) file://$site/\$1" "$site/$prefix/**/*.html" + done docs:test:browser: desc: Test the homepage YAML explorer on desktop and mobile Chromium. @@ -243,7 +220,7 @@ tasks: desc: Validate YAML syntax and formatting without Python. aliases: [lint-yaml] cmds: - - prek run --config hack/prek-builtins.toml check-yaml --all-files + - prek run check-yaml --all-files - task: lint:yaml:format lint:actions: @@ -252,15 +229,10 @@ tasks: cmds: - actionlint -pyflakes='' - lint:shell: - desc: ShellCheck the inline Task commands. - cmds: - - go test -tags=tooling ./hack/tooling -run '^TestTaskShell$' -count=1 - lint:files: desc: Run native prek file hygiene and syntax checks. cmds: - - prek run --config hack/prek-builtins.toml {{if .CLI_ARGS}}--files {{.CLI_ARGS}}{{else}}--all-files{{end}} + - prek run --group files {{if .CLI_ARGS}}--files {{.CLI_ARGS}}{{else}}--all-files{{end}} lint:commits: desc: Validate Conventional Commits between COMMIT_BASE and COMMIT_HEAD. @@ -297,9 +269,9 @@ tasks: cmds: - task: generate - task: manifests - - git diff --exit-code -- api charts/change-windows/crds charts/change-windows/files + - git diff --exit-code -- ':(glob)api/**/zz_generated*.go' charts/change-windows/crds charts/change-windows/files - | - if [ -n "$(git ls-files --others --exclude-standard api charts/change-windows/crds charts/change-windows/files)" ]; then + if [ -n "$(git ls-files --others --exclude-standard -- ':(glob)api/**/zz_generated*.go' charts/change-windows/crds charts/change-windows/files)" ]; then echo 'Untracked generated files found in api/ or Helm chart assets' >&2 exit 1 fi @@ -539,7 +511,7 @@ tasks: done # Probe through the API server to check TLS and admission registration, with no persisted object. deadline=$((SECONDS + DEV_TIMEOUT)) - until "${kube[@]}" create --dry-run=server -f hack/dev/readiness.yaml > "$temp/probe.log" 2>&1; do + until "${kube[@]}" create --dry-run=server -f playground/manifests/dev/readiness.yaml > "$temp/probe.log" 2>&1; do if (( SECONDS >= deadline )); then cat "$temp/probe.log" >&2; exit 1; fi sleep 1 done @@ -618,7 +590,7 @@ tasks: - printf '%s' "${REGISTRY_PASSWORD:?REGISTRY_PASSWORD is required}" | helm registry login "$REGISTRY" --username "${REGISTRY_USERNAME:?REGISTRY_USERNAME is required}" --password-stdin playground: - desc: Run a disposable Kind playground with Headlamp for up to 30 minutes, then delete it. + desc: Run a disposable Kind playground with Flux-managed Headlamp for up to 30 minutes. env: PLAYGROUND_CLUSTER: '{{.PLAYGROUND_CLUSTER | default "change-windows-playground"}}' PLAYGROUND_MINUTES: '{{.PLAYGROUND_MINUTES | default "30"}}' @@ -661,14 +633,14 @@ tasks: trap 'exit 130' INT trap 'exit 143' TERM + root="$PWD" task headlamp:install headlamp:build mkdir -p "$temp/plugins/capsule-change-windows" cp headlamp/dist/main.js headlamp/dist/package.json "$temp/plugins/capsule-change-windows/" chmod -R a+rX "$temp/plugins" - # JSON is valid YAML and safely quotes the host path, including spaces. - node -e 'console.log(JSON.stringify({kind: "Cluster", apiVersion: "kind.x-k8s.io/v1alpha4", nodes: [{role: "control-plane", extraMounts: [{hostPath: process.argv[1], containerPath: "/opt/change-windows-plugins", readOnly: true}]}]}))' "$temp/plugins" > "$temp/kind.json" cluster_started=true - kind create cluster --name "$PLAYGROUND_CLUSTER" --image "$KIND_NODE_IMAGE" --config "$temp/kind.json" --kubeconfig "$KUBECONFIG" --wait 120s + (cd "$temp" && kind create cluster --name "$PLAYGROUND_CLUSTER" --image "$KIND_NODE_IMAGE" \ + --config "$root/playground/kind.config.yaml" --kubeconfig "$KUBECONFIG" --wait 120s) kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.2/cert-manager.yaml for deployment in cert-manager cert-manager-webhook cert-manager-cainjector; do kubectl -n cert-manager rollout status "deployment/$deployment" --timeout=120s @@ -678,15 +650,15 @@ tasks: export DEV_RELEASE=change-windows export DEV_IMAGE_MODE=kind export DEV_KIND_CLUSTER="$PLAYGROUND_CLUSTER" - export DEV_HELM_VALUES=hack/playground/operator-values.yaml + export DEV_HELM_VALUES=playground/installation/change-windows/values.yaml export IMG=change-windows:playground export IMAGE_ARCHIVE="$temp/controller.tar" task dev:deploy - kubectl apply -f hack/playground/resources.yaml -f examples/executor-rbac.yaml - kubectl apply -f hack/playground/window.yaml + kubectl apply -k playground/manifests kubectl wait changewindow/playground --for=condition=Ready --timeout=120s - helm upgrade --install headlamp headlamp --repo https://kubernetes-sigs.github.io/headlamp/ --version 0.45.0 \ - --namespace kube-system --values hack/playground/headlamp-values.yaml --wait --timeout 180s + flux install --components=source-controller,helm-controller --watch-all-namespaces=false --timeout=180s + kubectl apply -k playground/installation/headlamp + kubectl -n flux-system wait helmrelease/headlamp --for=condition=Ready --timeout=300s kubectl -n kube-system rollout status deployment/headlamp --timeout=120s kubectl -n kube-system port-forward service/headlamp "$PLAYGROUND_PORT:80" --address=127.0.0.1 > "$temp/forward.log" 2>&1 & forward_pid=$! @@ -886,14 +858,12 @@ tasks: check: desc: Run the checks used by CI (e2e runs separately). cmds: - - task: test:tooling - task: test - task: lint - task: lint:config - task: lint:files - task: lint:yaml - task: lint:actions - - task: lint:shell - task: helm:lint - task: verify-generated - task: build diff --git a/hack/boilerplate.go.txt b/api/boilerplate.go.txt similarity index 100% rename from hack/boilerplate.go.txt rename to api/boilerplate.go.txt diff --git a/devbox.json b/devbox.json index 7689d53..77f0202 100644 --- a/devbox.json +++ b/devbox.json @@ -22,12 +22,21 @@ "bash": "5", "helm-docs": "1.14.2", "goreleaser": "2.17.1", - "openssl": "3.6.4" + "openssl": "3.6.4", + "lychee": "0.24.2", + "fluxcd": "2.9.6" }, "env": { - "GOTOOLCHAIN": "local" + "GOTOOLCHAIN": "local", + "CRDOC_VERSION": "v0.6.4" }, "nixpkgs": { "commit": "8edc0c72e3a38faf5434e40d1f19431125fe4b30" + }, + "shell": { + "init_hook": [ + "export PATH=\"$DEVBOX_PROJECT_ROOT/.devbox/crdoc/$CRDOC_VERSION/bin:$PATH\"", + "test -x \"$DEVBOX_PROJECT_ROOT/.devbox/crdoc/$CRDOC_VERSION/bin/crdoc\" || GOBIN=\"$DEVBOX_PROJECT_ROOT/.devbox/crdoc/$CRDOC_VERSION/bin\" go install \"fybrik.io/crdoc@$CRDOC_VERSION\"" + ] } } diff --git a/devbox.lock b/devbox.lock index 569c7a9..78702d6 100644 --- a/devbox.lock +++ b/devbox.lock @@ -194,6 +194,44 @@ } } }, + "fluxcd@2.9.6": { + "last_modified": "2026-10-08T19:21:32Z", + "resolved": "github:NixOS/nixpkgs/8edc0c72e3a38faf5434e40d1f19431125fe4b30#fluxcd", + "source": "devbox-search", + "version": "2.9.6", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/rllw4j60v1p8picvv7s58k6nbl760jw2-fluxcd-2.9.6", + "default": true + } + ], + "store_path": "/nix/store/rllw4j60v1p8picvv7s58k6nbl760jw2-fluxcd-2.9.6" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/d3jw2wilc16nwh821bpbmln9zxz35yvb-fluxcd-2.9.6", + "default": true + } + ], + "store_path": "/nix/store/d3jw2wilc16nwh821bpbmln9zxz35yvb-fluxcd-2.9.6" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/i9l7mscqvgr5zby11dg4yngn1r71gqsi-fluxcd-2.9.6", + "default": true + } + ], + "store_path": "/nix/store/i9l7mscqvgr5zby11dg4yngn1r71gqsi-fluxcd-2.9.6" + } + } + }, "git@2": { "last_modified": "2026-10-08T19:21:32Z", "resolved": "github:NixOS/nixpkgs/8edc0c72e3a38faf5434e40d1f19431125fe4b30#git", @@ -779,6 +817,54 @@ } } }, + "lychee@0.24.2": { + "last_modified": "2026-10-08T19:21:32Z", + "resolved": "github:NixOS/nixpkgs/8edc0c72e3a38faf5434e40d1f19431125fe4b30#lychee", + "source": "devbox-search", + "version": "0.24.2", + "systems": { + "aarch64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/1wnjphvm60xch0r892p16hpmsavwc5wf-lychee-0.24.2", + "default": true + } + ], + "store_path": "/nix/store/1wnjphvm60xch0r892p16hpmsavwc5wf-lychee-0.24.2" + }, + "aarch64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/cq1g1j8fwyb41l5xpkb107271p3qkyqh-lychee-0.24.2", + "default": true + } + ], + "store_path": "/nix/store/cq1g1j8fwyb41l5xpkb107271p3qkyqh-lychee-0.24.2" + }, + "x86_64-darwin": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/wrkqg7j3wqg6hfrmx7cyvaiirwfxla5y-lychee-0.24.2", + "default": true + } + ], + "store_path": "/nix/store/wrkqg7j3wqg6hfrmx7cyvaiirwfxla5y-lychee-0.24.2" + }, + "x86_64-linux": { + "outputs": [ + { + "name": "out", + "path": "/nix/store/zf1zpdxlgfccp0yfdaw8v9y2lga77bwd-lychee-0.24.2", + "default": true + } + ], + "store_path": "/nix/store/zf1zpdxlgfccp0yfdaw8v9y2lga77bwd-lychee-0.24.2" + } + } + }, "nodejs@24.21.0": { "last_modified": "2026-10-08T19:21:32Z", "plugin_version": "0.0.5", diff --git a/docs/api-reference.yaml b/docs/api-reference.yaml index 6e1464c..bd07458 100644 --- a/docs/api-reference.yaml +++ b/docs/api-reference.yaml @@ -1,7 +1,9 @@ -processor: - ignoreTypes: - - ChangeWindowList$ - ignoreFields: - - TypeMeta$ -render: - kubernetesVersion: '1.36' +metadata: + title: API reference + weight: 10 + description: Generated reference for the ChangeWindow Kubernetes API. +groups: + - group: addons.projectcapsule.dev + version: v1alpha1 + kinds: + - name: ChangeWindow diff --git a/docs/content/docs/development/contributing.md b/docs/content/docs/development/contributing.md index faef107..02f9190 100644 --- a/docs/content/docs/development/contributing.md +++ b/docs/content/docs/development/contributing.md @@ -20,13 +20,16 @@ Xcode Command Line Tools for Go's race detector. ko builds container images with a daemon. Docker or another compatible engine is a separate prerequisite for Kind tests. -Task implementations live directly in `Taskfile.yml`. Every prek hook calls a -Task target through `devbox run -- task`, including file checks, YAML formatting, -and commit-message validation. `task lint:shell` checks inline Task commands with -ShellCheck. The native checks use a separate builtin-only prek configuration to -avoid calling the Task hooks recursively. No Python environment is required. +Task implementations live directly in `Taskfile.yml`. Prefer predefined prek +hooks: native file checks live in `prek.toml`; project-specific hooks call existing +Task targets through `devbox run -- task`. `task lint:files` selects the builtin +`files` group without recursively invoking Task hooks. No Python is required. `task hooks:install` replaces legacy pre-commit shims. Use -`devbox run -- task COMMAND` to run any target without an interactive shell. +`devbox run -- task COMMAND` to run a target without an interactive shell. + +Use native validators and dry runs for tooling changes. Keep tests beside the +product code or in its existing browser/integration suite; avoid loose Go/JS +harnesses for configuration text, dependency versions, or repository layout. ## Run the controller on your host @@ -142,8 +145,9 @@ helm --kube-context my-dev uninstall change-windows --namespace change-windows-s | `internal/metrics/` | Bounded-label Prometheus metrics. | | `cmd/main.go` | Manager, discovery, dependencies, TLS, and registration. | | `charts/` | Helm chart and generated CRDs/RBAC. | -| `examples/` | Executable ChangeWindow and executor RBAC examples. | -| `e2e/`, `hack/` | Isolated Kind tests and development/release helpers. | +| `playground/manifests/examples/` | Executable ChangeWindow and executor RBAC examples. | +| `e2e/` | Isolated Kind integration tests. | +| `playground/` | Kind configuration, Flux installation, and demo/example manifests. | | `docs/` | Hugo site, pinned Doks npm dependencies, guides, and generated API reference. | | `headlamp/` | Headlamp plugin, schedule previews, selection helpers, and UI/browser tests. | @@ -177,8 +181,7 @@ build files during API changes; retain Task as the maintained workflow. ```shell task test:unit task test -task test:tooling -task lint lint:yaml lint:actions lint:shell helm:lint +task lint lint:yaml lint:actions helm:lint task e2e IMG=ghcr.io/projectcapsule/change-windows:e2e \ KIND_CLUSTER=scheduled-action-dev ``` @@ -186,9 +189,8 @@ task e2e IMG=ghcr.io/projectcapsule/change-windows:e2e \ `test:unit` excludes the real API-server suite. `test` runs unit and envtest integration tests with the race detector; setup-envtest obtains the pinned API server assets. Set `KUBEBUILDER_ASSETS` to an existing directory to reuse them. -`test:tooling` verifies Task, hook, Helm, and release-command behavior without -publishing or touching a cluster. `docs:check` checks generated-reference drift, -example validity, and rendered links and assets. +`docs:check` checks generated-reference drift and rendered links and assets. +Executable examples are validated by the policy compiler unit tests. The e2e task creates a new isolated Kind cluster with a private kubeconfig, installs cert-manager, and runs `dev:deploy` twice to check Helm installation and @@ -236,25 +238,31 @@ then run the documentation checks and inspect the rendered site. Edit weighted Markdown pages under `docs/content/docs/`. Group indexes define the sidebar. Use Hugo `relref` links between pages so navigation also works on project sites. The shared `example` shortcode and download mounts read directly -from `examples/`; do not duplicate executable examples. +from `playground/manifests/examples/`; do not duplicate executable examples. -The homepage explorer renders `examples/quickstart.yaml` without changing +The homepage explorer renders `playground/manifests/examples/quickstart.yaml` without changing its content. Annotations in `docs/data/window_example.yaml` connect each top-level spec block to its explanation, guide, and API type. Keep those links accurate as -the API evolves. The static checks verify that copyable YAML and the download +the API evolves. The browser tests verify that copyable YAML and the download exactly match the executable sample. -`docs/content/docs/reference/api.md` is generated by the pinned `crd-ref-docs` -Go tool in `hack/tools/`. Run `task docs:generate` after changing API types. -The generator's dependencies stay outside the controller module. The empty -`docs/go.mod` also prevents Go tools from traversing npm dependencies. - -`task docs:check` checks generated-reference drift, validates examples, and -builds root and project-site variants. It verifies every page's local links, -anchors, assets, search index, and downloadable examples. For theme changes, -also check search, code copying, dark mode, and mobile navigation in a browser. -The Sass partial suppresses only known deprecations in Doks/Bootstrap; other -build warnings still fail validation. +`docs/content/docs/reference/api.md` is generated from the Helm CRDs by +[crdoc](https://github.com/fybrik/crdoc). Run `task docs:generate` after changing API +types; it refreshes chart manifests first. `docs/api-reference.yaml` supplies the +title and API selection to crdoc's native frontmatter template. + +crdoc is not currently indexed by Devbox. The Devbox initialization hook installs +the pinned `fybrik.io/crdoc` CLI into `.devbox/crdoc//bin` on first use +and adds it to PATH. Change `CRDOC_VERSION` in `devbox.json` to upgrade it. This +needs no wrapper, extra Go module, or controller dependency. The empty +`docs/go.mod` keeps npm dependencies outside controller Go tooling. + +`task docs:check` checks generated-reference drift, builds root and project-site +variants, and uses lychee to check local links, fragments, and assets offline. +External website availability does not affect this check. Policy unit tests +validate the executable ChangeWindow examples. For theme changes, also run the +browser tests for search, copying, dark mode, and mobile navigation. Known +Doks/Bootstrap Sass deprecations are suppressed; other build warnings fail. For interactive documentation changes, install Chromium once and run the browser suite. It covers desktop/mobile, keyboard selection, copying, dark mode, and the diff --git a/docs/content/docs/development/playground.md b/docs/content/docs/development/playground.md index 1ad864c..2c79ffc 100644 --- a/docs/content/docs/development/playground.md +++ b/docs/content/docs/development/playground.md @@ -5,8 +5,9 @@ weight: 20 --- The playground builds the current checkout's operator with ko and its Headlamp -plugin, then installs both with Headlamp in a new Kind cluster. It includes a -demo workload and a ChangeWindow so you can explore the calendar, maintenance +plugin, then installs both in a new Kind cluster. Flux manages Headlamp through +a HelmRelease. A demo workload and ChangeWindow let you explore the calendar, +maintenance rules and automatic downscaling. No registry publication or existing cluster is involved. @@ -31,6 +32,34 @@ devbox run -- task playground PLAYGROUND_MINUTES=10 PLAYGROUND_PORT=18082 `PLAYGROUND_CLUSTER` selects a different **new** Kind cluster name. Dependencies use pinned versions; the operator and plugin come from the current working tree. +## Configuration and manifests + +All playground inputs live under `playground/`: + +- `kind.config.yaml` declares the Kind node and read-only plugin mount. The task + stages the current plugin in a private directory and runs Kind from there, so + the relative `./plugins` mount works without generating configuration. +- `installation/headlamp/` contains the Flux HelmRepository and HelmRelease. + The source and Helm controllers run in `flux-system`; Headlamp runs in + `kube-system`. Its chart version and values are declared in `release.flux.yaml`. +- `installation/change-windows/values.yaml` configures the local operator's Helm + release, including admission selection for the `development` namespace. +- `manifests/demo/` holds the demo resources and maintenance window. + `manifests/examples/` holds the individual documented examples, shared directly + with the documentation and tests. `manifests/dev/` holds the dry-run admission + probe used by development deployment. + +The task applies `playground/manifests/kustomization.yaml`, which selects only the +runnable demo and its executor RBAC. Other examples are opt-in; do not recursively +apply every sample because their schedules and policies may overlap. + +Preview the manifests without installing anything: + +```sh +kubectl kustomize playground/installation/headlamp +kubectl kustomize playground/manifests +``` + ## Explore the demo Open **Change Windows** in Headlamp's navigation. The `playground` window selects diff --git a/docs/content/docs/getting-started/installation.md b/docs/content/docs/getting-started/installation.md index ae3b4bf..64ee548 100644 --- a/docs/content/docs/getting-started/installation.md +++ b/docs/content/docs/getting-started/installation.md @@ -52,7 +52,7 @@ the executor you created. ```shell kubectl create namespace maintenance-demo kubectl label namespace maintenance-demo maintenance=enabled -kubectl apply -f examples/maintenance-only.yaml +kubectl apply -f playground/manifests/examples/maintenance-only.yaml kubectl get changewindows kubectl get changewindow weekly-maintenance -o yaml ``` diff --git a/docs/content/docs/reference/api.md b/docs/content/docs/reference/api.md index 352c6b1..899fb2a 100644 --- a/docs/content/docs/reference/api.md +++ b/docs/content/docs/reference/api.md @@ -4,250 +4,1380 @@ weight: 10 description: Generated reference for the ChangeWindow Kubernetes API. --- - +Packages: -## Packages - [addons.projectcapsule.dev/v1alpha1](#addonsprojectcapsuledevv1alpha1) -## addons.projectcapsule.dev/v1alpha1 +# addons.projectcapsule.dev/v1alpha1 -Package v1alpha1 contains API Schema definitions for the addons v1alpha1 API group. +Resource Types: -### Resource Types - [ChangeWindow](#changewindow) -## ActionStep -_Appears in:_ -- [ResourceAction](#resourceaction) -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apply` _[Apply](#apply)_ | | | | -## AdmissionConfig - -_Appears in:_ -- [Config](#config) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `warnings` _[AdmissionWarning](#admissionwarning) array_ | | | MaxItems: 32
Optional: \{\}
| - -## AdmissionRule - -_Appears in:_ -- [ChangeWindowSpec](#changewindowspec) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | | | MinLength: 1
| -| `targets` _string array_ | | | MinItems: 1
| -| `operations` _string array_ | | | MinItems: 1
items:Enum: [CREATE UPDATE DELETE]
| -| `subresources` _string array_ | Subresources defaults to the parent resource only. Use "scale" for /scale,
"*" for all subresources, and ["", "*"] for parent and subresource writes. | | Optional: \{\}
| -| `audience` _[Audience](#audience)_ | Audience selects authenticated request identities. Omitted or empty means everyone. | | Optional: \{\}
| -| `matchConditions` _[MatchCondition](#matchcondition) array_ | MatchConditions are ANDed CEL expressions. An empty list matches all requests.
A false result skips this entry. Evaluation errors fail closed for denial rules;
warning errors return a diagnostic warning without denying the request.
Variables: object, oldObject, request, namespaceObject. Objects are the actual
admission payloads (e.g. Scale for /scale); absent objects are null. | | MaxItems: 64
Optional: \{\}
| -| `when` _string_ | | Active | Enum: [Active Inactive]
| -| `action` _string_ | | Deny | Enum: [Deny]
| -| `message` _string_ | Message is a Go template with target and window context. | | MaxLength: 4096
MinLength: 1
| - -## AdmissionWarning - -_Appears in:_ -- [AdmissionConfig](#admissionconfig) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | | | MinLength: 1
| -| `targets` _string array_ | | | MinItems: 1
| -| `operations` _string array_ | | | MinItems: 1
items:Enum: [CREATE UPDATE DELETE]
| -| `subresources` _string array_ | Subresources defaults to the parent resource only. Use "scale" for /scale,
"*" for all subresources, and ["", "*"] for parent and subresource writes. | | Optional: \{\}
| -| `audience` _[Audience](#audience)_ | Audience selects authenticated request identities. Omitted or empty means everyone. | | Optional: \{\}
| -| `matchConditions` _[MatchCondition](#matchcondition) array_ | MatchConditions are ANDed CEL expressions. An empty list matches all requests.
A false result skips this entry. Evaluation errors fail closed for denial rules;
warning errors return a diagnostic warning without denying the request.
Variables: object, oldObject, request, namespaceObject. Objects are the actual
admission payloads (e.g. Scale for /scale); absent objects are null. | | MaxItems: 64
Optional: \{\}
| -| `when` _string_ | | | Enum: [BeforeStart Active BeforeEnd]
| -| `within` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#duration-v1-meta)_ | Within is required for BeforeStart/BeforeEnd and must be positive, at most 168h. | | Optional: \{\}
| -| `message` _string_ | | | MaxLength: 4096
MinLength: 1
| - -## Apply - -_Appears in:_ -- [ActionStep](#actionstep) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `subresource` _string_ | Subresource is an actual Kubernetes subresource, e.g. scale (not rollout). | | Optional: \{\}
| -| `force` _boolean_ | Force explicitly transfers ownership of conflicting fields. | | Optional: \{\}
| -| `template` _string_ | Template renders a partial YAML/JSON object. Identity is supplied by the controller. | | MaxLength: 65536
MinLength: 1
| - -## Audience - -Audience matches any include subject unless any exclude subject matches. -Empty include matches everyone. Subjects are exact, case-sensitive identities; -matching a subject does not grant RBAC permissions. - -_Appears in:_ -- [AdmissionRule](#admissionrule) -- [AdmissionWarning](#admissionwarning) -- [RequestMatch](#requestmatch) +## ChangeWindow +[↩ Parent](#addonsprojectcapsuledevv1alpha1 ) -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `include` _[Subject](#subject) array_ | | | MaxItems: 64
Optional: \{\}
| -| `exclude` _[Subject](#subject) array_ | | | MaxItems: 64
Optional: \{\}
| -## Capture -_Appears in:_ -- [ResourceAction](#resourceaction) -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `subresource` _string_ | | | Optional: \{\}
| -| `path` _string_ | Path is an RFC 6901 JSON pointer. | | Pattern: `^/`
| -## ChangeWindow ChangeWindow describes scheduled resource changes and admission restrictions. -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `apiVersion` _string_ | `addons.projectcapsule.dev/v1alpha1` | | | -| `kind` _string_ | `ChangeWindow` | | | -| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | Optional: \{\}
| -| `spec` _[ChangeWindowSpec](#changewindowspec)_ | | | | -| `status` _[ChangeWindowStatus](#changewindowstatus)_ | | | Optional: \{\}
| + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescriptionRequired
apiVersionstringaddons.projectcapsule.dev/v1alpha1true
kindstringChangeWindowtrue
metadataobjectRefer to the Kubernetes API documentation for the fields of the `metadata` field.true
specobject + ChangeWindowSpec selects namespaces and resources for scheduled behavior.
+
+ Validations:
  • !has(self.actions) || size(self.actions) == 0 || has(self.serviceAccount): actions require a serviceAccount
  • +
    true
    statusobject +
    +
    false
    + + +### ChangeWindow.spec +[↩ Parent](#changewindow) -## ChangeWindowSpec -ChangeWindowSpec selects namespaces and resources for scheduled behavior. -_Appears in:_ -- [ChangeWindow](#changewindow) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `namespaceSelectors` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#labelselector-v1-meta) array_ | NamespaceSelectors are ORed for namespaced targets. Required when any target is namespaced.
    Omit for Node-only windows; Node targets use their own label selector.
    An explicit empty selector matches all non-protected namespaces. | | MaxItems: 16
    MinItems: 1
    Optional: \{\}
    | -| `schedules` _[Schedule](#schedule) array_ | Schedules form a union of overlapping and adjacent windows. | | MaxItems: 16
    MinItems: 1
    | -| `targets` _[Target](#target) array_ | | | MaxItems: 32
    MinItems: 1
    | -| `serviceAccount` _[ServiceAccountReference](#serviceaccountreference)_ | ServiceAccount is the identity used for resource reads and SSA writes. | | Optional: \{\}
    | -| `actions` _[ResourceAction](#resourceaction) array_ | | | MaxItems: 16
    Optional: \{\}
    | -| `admission` _[AdmissionRule](#admissionrule) array_ | | | MaxItems: 32
    Optional: \{\}
    | -| `config` _[Config](#config)_ | | | Optional: \{\}
    | +ChangeWindowSpec selects namespaces and resources for scheduled behavior. -## ChangeWindowStatus + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    schedules[]object + Schedules form a union of overlapping and adjacent windows.
    +
    true
    targets[]object +
    +
    true
    actions[]object +
    +
    false
    admission[]object +
    +
    false
    configobject +
    +
    false
    namespaceSelectors[]object + NamespaceSelectors are ORed for namespaced targets. Required when any target is namespaced. +Omit for Node-only windows; Node targets use their own label selector. +An explicit empty selector matches all non-protected namespaces.
    +
    false
    serviceAccountobject + ServiceAccount is the identity used for resource reads and SSA writes.
    +
    false
    + + +### ChangeWindow.spec.schedules[index] +[↩ Parent](#changewindowspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    cronstring + Cron is a standard five-field cron expression; seconds and TZ prefixes are not accepted.
    +
    true
    durationstring + Duration is elapsed time, greater than zero and at most 168h.
    +
    true
    namestring +
    +
    true
    timeZonestring +
    +
    + Default: UTC
    +
    false
    + + +### ChangeWindow.spec.targets[index] +[↩ Parent](#changewindowspec) -_Appears in:_ -- [ChangeWindow](#changewindow) -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `observedGeneration` _integer_ | | | Optional: \{\}
    | -| `active` _boolean_ | Active mirrors the Active condition for compatibility. Conditions distinguish unknown state. | | Optional: \{\}
    | -| `windowStart` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta)_ | | | Optional: \{\}
    | -| `windowEnd` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta)_ | WindowEnd is absent when the effective end is beyond the bounded scheduling horizon. | | Optional: \{\}
    | -| `nextTransition` _[Time](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#time-v1-meta)_ | NextTransition is the active window's end or the next planned start, including after Finished. | | Optional: \{\}
    | -| `pendingResources` _integer_ | PendingResources counts journal entries still requiring completion. | | Optional: \{\}
    | -| `conditions` _[Condition](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#condition-v1-meta) array_ | Conditions report reconciliation readiness (Ready) and schedule occupancy (Active).
    Active reasons are Planned before the first observed window, Active during a window,
    Finishing while restoration is outstanding, and Finished after an observed window completes.
    Finished persists until another window starts. Unknown denotes invalid policy;
    Unscheduled means no occurrence is available and none has been observed. | | Optional: \{\}
    | -## Config - -_Appears in:_ -- [ChangeWindowSpec](#changewindowspec) +Target selects a resource kind and labels. Admission targets support Capsule GVK +patterns and match namespaced resources only, except concrete v1 Node targets. +Scheduled actions require a concrete namespaced apiVersion and kind. -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `admission` _[AdmissionConfig](#admissionconfig)_ | | | Optional: \{\}
    | + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    kindstring + Kind is case-sensitive and supports * patterns, for example *Set or Deploy*.
    +
    true
    namestring +
    +
    true
    apiVersionstring + APIVersion uses Capsule GVK matching: omitted/empty means core v1; +apps matches any apps version, apps/v1 is exact, and * matches all groups +and versions. Partial patterns such as apps/* and *.example.com/v1 work too.
    +
    false
    selectorobject + Selector selects resource labels. For Nodes it selects nodes directly; +for namespaced targets it applies within the selected namespaces.
    +
    false
    + + +### ChangeWindow.spec.targets[index].selector +[↩ Parent](#changewindowspectargetsindex) + + + +Selector selects resource labels. For Nodes it selects nodes directly; +for namespaced targets it applies within the selected namespaces. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    matchExpressions[]object + matchExpressions is a list of label selector requirements. The requirements are ANDed.
    +
    false
    matchLabelsmap[string]string + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels +map is equivalent to an element of matchExpressions, whose key field is "key", the +operator is "In", and the values array contains only "value". The requirements are ANDed.
    +
    false
    + + +### ChangeWindow.spec.targets[index].selector.matchExpressions[index] +[↩ Parent](#changewindowspectargetsindexselector) + + + +A label selector requirement is a selector that contains values, a key, and an operator that +relates the key and values. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    keystring + key is the label key that the selector applies to.
    +
    true
    operatorstring + operator represents a key's relationship to a set of values. +Valid operators are In, NotIn, Exists and DoesNotExist.
    +
    true
    values[]string + values is an array of string values. If the operator is In or NotIn, +the values array must be non-empty. If the operator is Exists or DoesNotExist, +the values array must be empty. This array is replaced during a strategic +merge patch.
    +
    false
    + + +### ChangeWindow.spec.actions[index] +[↩ Parent](#changewindowspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring +
    +
    true
    targets[]string +
    +
    true
    capturemap[string]object + Capture stores only explicitly selected JSON pointer values before any mutation.
    +
    false
    onEndobject +
    +
    false
    onStartobject +
    +
    false
    + + +### ChangeWindow.spec.actions[index].capture[key] +[↩ Parent](#changewindowspecactionsindex) + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    pathstring + Path is an RFC 6901 JSON pointer.
    +
    true
    subresourcestring +
    +
    false
    + + +### ChangeWindow.spec.actions[index].onEnd +[↩ Parent](#changewindowspecactionsindex) + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    applyobject +
    +
    true
    + + +### ChangeWindow.spec.actions[index].onEnd.apply +[↩ Parent](#changewindowspecactionsindexonend) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    templatestring + Template renders a partial YAML/JSON object. Identity is supplied by the controller.
    +
    true
    forceboolean + Force explicitly transfers ownership of conflicting fields.
    +
    false
    subresourcestring + Subresource is an actual Kubernetes subresource, e.g. scale (not rollout).
    +
    false
    + + +### ChangeWindow.spec.actions[index].onStart +[↩ Parent](#changewindowspecactionsindex) + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    applyobject +
    +
    true
    + + +### ChangeWindow.spec.actions[index].onStart.apply +[↩ Parent](#changewindowspecactionsindexonstart) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    templatestring + Template renders a partial YAML/JSON object. Identity is supplied by the controller.
    +
    true
    forceboolean + Force explicitly transfers ownership of conflicting fields.
    +
    false
    subresourcestring + Subresource is an actual Kubernetes subresource, e.g. scale (not rollout).
    +
    false
    + + +### ChangeWindow.spec.admission[index] +[↩ Parent](#changewindowspec) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    messagestring + Message is a Go template with target and window context.
    +
    true
    namestring +
    +
    true
    operations[]enum +
    +
    + Enum: CREATE, UPDATE, DELETE
    +
    true
    targets[]string +
    +
    true
    actionenum +
    +
    + Enum: Deny
    + Default: Deny
    +
    false
    audienceobject + Audience selects authenticated request identities. Omitted or empty means everyone.
    +
    false
    matchConditions[]object + MatchConditions are ANDed CEL expressions. An empty list matches all requests. +A false result skips this entry. Evaluation errors fail closed for denial rules; +warning errors return a diagnostic warning without denying the request. +Variables: object, oldObject, request, namespaceObject. Objects are the actual +admission payloads (e.g. Scale for /scale); absent objects are null.
    +
    false
    subresources[]string + Subresources defaults to the parent resource only. Use "scale" for /scale, +"*" for all subresources, and ["", "*"] for parent and subresource writes.
    +
    false
    whenenum +
    +
    + Enum: Active, Inactive
    + Default: Active
    +
    false
    + + +### ChangeWindow.spec.admission[index].audience +[↩ Parent](#changewindowspecadmissionindex) + + + +Audience selects authenticated request identities. Omitted or empty means everyone. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    exclude[]object +
    +
    false
    include[]object +
    +
    false
    + + +### ChangeWindow.spec.admission[index].audience.exclude[index] +[↩ Parent](#changewindowspecadmissionindexaudience) -## MatchCondition -MatchCondition is a named CEL boolean expression compiled with the policy. -_Appears in:_ -- [AdmissionRule](#admissionrule) -- [AdmissionWarning](#admissionwarning) -- [RequestMatch](#requestmatch) +Subject identifies an authenticated user, group, or namespaced service account. -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | | | MaxLength: 63
    MinLength: 1
    Pattern: `^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`
    | -| `expression` _string_ | | | MaxLength: 4096
    MinLength: 1
    | + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    kindenum +
    +
    + Enum: User, Group, ServiceAccount
    +
    true
    namestring +
    +
    true
    namespacestring + Namespace is mandatory for ServiceAccounts and forbidden for Users and Groups.
    +
    false
    + + +### ChangeWindow.spec.admission[index].audience.include[index] +[↩ Parent](#changewindowspecadmissionindexaudience) -## RequestMatch -RequestMatch restricts every admission rule and warning by request, identity and CEL conditions. -_Appears in:_ -- [AdmissionRule](#admissionrule) -- [AdmissionWarning](#admissionwarning) +Subject identifies an authenticated user, group, or namespaced service account. -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `targets` _string array_ | | | MinItems: 1
    | -| `operations` _string array_ | | | MinItems: 1
    items:Enum: [CREATE UPDATE DELETE]
    | -| `subresources` _string array_ | Subresources defaults to the parent resource only. Use "scale" for /scale,
    "*" for all subresources, and ["", "*"] for parent and subresource writes. | | Optional: \{\}
    | -| `audience` _[Audience](#audience)_ | Audience selects authenticated request identities. Omitted or empty means everyone. | | Optional: \{\}
    | -| `matchConditions` _[MatchCondition](#matchcondition) array_ | MatchConditions are ANDed CEL expressions. An empty list matches all requests.
    A false result skips this entry. Evaluation errors fail closed for denial rules;
    warning errors return a diagnostic warning without denying the request.
    Variables: object, oldObject, request, namespaceObject. Objects are the actual
    admission payloads (e.g. Scale for /scale); absent objects are null. | | MaxItems: 64
    Optional: \{\}
    | + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    kindenum +
    +
    + Enum: User, Group, ServiceAccount
    +
    true
    namestring +
    +
    true
    namespacestring + Namespace is mandatory for ServiceAccounts and forbidden for Users and Groups.
    +
    false
    + + +### ChangeWindow.spec.admission[index].matchConditions[index] +[↩ Parent](#changewindowspecadmissionindex) -## ResourceAction -_Appears in:_ -- [ChangeWindowSpec](#changewindowspec) -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | | | MinLength: 1
    | -| `targets` _string array_ | | | MinItems: 1
    | -| `capture` _object (keys:string, values:[Capture](#capture))_ | Capture stores only explicitly selected JSON pointer values before any mutation. | | MaxProperties: 32
    Optional: \{\}
    | -| `onStart` _[ActionStep](#actionstep)_ | | | Optional: \{\}
    | -| `onEnd` _[ActionStep](#actionstep)_ | | | Optional: \{\}
    | +MatchCondition is a named CEL boolean expression compiled with the policy. -## Schedule + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    expressionstring +
    +
    true
    namestring +
    +
    true
    + + +### ChangeWindow.spec.config +[↩ Parent](#changewindowspec) + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    admissionobject +
    +
    false
    + + +### ChangeWindow.spec.config.admission +[↩ Parent](#changewindowspecconfig) + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    warnings[]object +
    +
    false
    + + +### ChangeWindow.spec.config.admission.warnings[index] +[↩ Parent](#changewindowspecconfigadmission) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    messagestring +
    +
    true
    namestring +
    +
    true
    operations[]enum +
    +
    + Enum: CREATE, UPDATE, DELETE
    +
    true
    targets[]string +
    +
    true
    whenenum +
    +
    + Enum: BeforeStart, Active, BeforeEnd
    +
    true
    audienceobject + Audience selects authenticated request identities. Omitted or empty means everyone.
    +
    false
    matchConditions[]object + MatchConditions are ANDed CEL expressions. An empty list matches all requests. +A false result skips this entry. Evaluation errors fail closed for denial rules; +warning errors return a diagnostic warning without denying the request. +Variables: object, oldObject, request, namespaceObject. Objects are the actual +admission payloads (e.g. Scale for /scale); absent objects are null.
    +
    false
    subresources[]string + Subresources defaults to the parent resource only. Use "scale" for /scale, +"*" for all subresources, and ["", "*"] for parent and subresource writes.
    +
    false
    withinstring + Within is required for BeforeStart/BeforeEnd and must be positive, at most 168h.
    +
    false
    + + +### ChangeWindow.spec.config.admission.warnings[index].audience +[↩ Parent](#changewindowspecconfigadmissionwarningsindex) + + + +Audience selects authenticated request identities. Omitted or empty means everyone. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    exclude[]object +
    +
    false
    include[]object +
    +
    false
    + + +### ChangeWindow.spec.config.admission.warnings[index].audience.exclude[index] +[↩ Parent](#changewindowspecconfigadmissionwarningsindexaudience) -_Appears in:_ -- [ChangeWindowSpec](#changewindowspec) -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | | | MaxLength: 63
    MinLength: 1
    | -| `cron` _string_ | Cron is a standard five-field cron expression; seconds and TZ prefixes are not accepted. | | MaxLength: 256
    | -| `timeZone` _string_ | | UTC | | -| `duration` _[Duration](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#duration-v1-meta)_ | Duration is elapsed time, greater than zero and at most 168h. | | | -## ServiceAccountReference +Subject identifies an authenticated user, group, or namespaced service account. -_Appears in:_ -- [ChangeWindowSpec](#changewindowspec) + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    kindenum +
    +
    + Enum: User, Group, ServiceAccount
    +
    true
    namestring +
    +
    true
    namespacestring + Namespace is mandatory for ServiceAccounts and forbidden for Users and Groups.
    +
    false
    + + +### ChangeWindow.spec.config.admission.warnings[index].audience.include[index] +[↩ Parent](#changewindowspecconfigadmissionwarningsindexaudience) -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `namespace` _string_ | | | MinLength: 1
    | -| `name` _string_ | | | MinLength: 1
    | -## Subject Subject identifies an authenticated user, group, or namespaced service account. -_Appears in:_ -- [Audience](#audience) - -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `kind` _string_ | | | Enum: [User Group ServiceAccount]
    | -| `name` _string_ | | | MaxLength: 256
    MinLength: 1
    | -| `namespace` _string_ | Namespace is mandatory for ServiceAccounts and forbidden for Users and Groups. | | MaxLength: 63
    MinLength: 1
    Optional: \{\}
    | + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    kindenum +
    +
    + Enum: User, Group, ServiceAccount
    +
    true
    namestring +
    +
    true
    namespacestring + Namespace is mandatory for ServiceAccounts and forbidden for Users and Groups.
    +
    false
    + + +### ChangeWindow.spec.config.admission.warnings[index].matchConditions[index] +[↩ Parent](#changewindowspecconfigadmissionwarningsindex) -## Target -Target selects a resource kind and labels. Admission targets support Capsule GVK -patterns and match namespaced resources only, except concrete v1 Node targets. -Scheduled actions require a concrete namespaced apiVersion and kind. -_Appears in:_ -- [ChangeWindowSpec](#changewindowspec) +MatchCondition is a named CEL boolean expression compiled with the policy. -| Field | Description | Default | Validation | -| --- | --- | --- | --- | -| `name` _string_ | | | MinLength: 1
    | -| `apiVersion` _string_ | APIVersion uses Capsule GVK matching: omitted/empty means core v1;
    apps matches any apps version, apps/v1 is exact, and * matches all groups
    and versions. Partial patterns such as apps/* and *.example.com/v1 work too. | | MaxLength: 256
    Optional: \{\}
    | -| `kind` _string_ | Kind is case-sensitive and supports * patterns, for example *Set or Deploy*. | | MaxLength: 256
    MinLength: 1
    | -| `selector` _[LabelSelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.36/#labelselector-v1-meta)_ | Selector selects resource labels. For Nodes it selects nodes directly;
    for namespaced targets it applies within the selected namespaces. | | Optional: \{\}
    | + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    expressionstring +
    +
    true
    namestring +
    +
    true
    + + +### ChangeWindow.spec.namespaceSelectors[index] +[↩ Parent](#changewindowspec) + + + +A label selector is a label query over a set of resources. The result of matchLabels and +matchExpressions are ANDed. An empty label selector matches all objects. A null +label selector matches no objects. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    matchExpressions[]object + matchExpressions is a list of label selector requirements. The requirements are ANDed.
    +
    false
    matchLabelsmap[string]string + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels +map is equivalent to an element of matchExpressions, whose key field is "key", the +operator is "In", and the values array contains only "value". The requirements are ANDed.
    +
    false
    + + +### ChangeWindow.spec.namespaceSelectors[index].matchExpressions[index] +[↩ Parent](#changewindowspecnamespaceselectorsindex) + + + +A label selector requirement is a selector that contains values, a key, and an operator that +relates the key and values. + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    keystring + key is the label key that the selector applies to.
    +
    true
    operatorstring + operator represents a key's relationship to a set of values. +Valid operators are In, NotIn, Exists and DoesNotExist.
    +
    true
    values[]string + values is an array of string values. If the operator is In or NotIn, +the values array must be non-empty. If the operator is Exists or DoesNotExist, +the values array must be empty. This array is replaced during a strategic +merge patch.
    +
    false
    + + +### ChangeWindow.spec.serviceAccount +[↩ Parent](#changewindowspec) + + + +ServiceAccount is the identity used for resource reads and SSA writes. + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    namestring +
    +
    true
    namespacestring +
    +
    true
    + + +### ChangeWindow.status +[↩ Parent](#changewindow) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    activeboolean + Active mirrors the Active condition for compatibility. Conditions distinguish unknown state.
    +
    false
    conditions[]object + Conditions report reconciliation readiness (Ready) and schedule occupancy (Active). +Active reasons are Planned before the first observed window, Active during a window, +Finishing while restoration is outstanding, and Finished after an observed window completes. +Finished persists until another window starts. Unknown denotes invalid policy; +Unscheduled means no occurrence is available and none has been observed.
    +
    false
    nextTransitionstring + NextTransition is the active window's end or the next planned start, including after Finished.
    +
    + Format: date-time
    +
    false
    observedGenerationinteger +
    +
    + Format: int64
    +
    false
    pendingResourcesinteger + PendingResources counts journal entries still requiring completion.
    +
    + Format: int32
    +
    false
    windowEndstring + WindowEnd is absent when the effective end is beyond the bounded scheduling horizon.
    +
    + Format: date-time
    +
    false
    windowStartstring +
    +
    + Format: date-time
    +
    false
    + + +### ChangeWindow.status.conditions[index] +[↩ Parent](#changewindowstatus) + + + +Condition contains details for one aspect of the current state of this API Resource. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescriptionRequired
    lastTransitionTimestring + lastTransitionTime is the last time the condition transitioned from one status to another. +This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
    +
    + Format: date-time
    +
    true
    messagestring + message is a human readable message indicating details about the transition. +This may be an empty string.
    +
    true
    reasonstring + reason contains a programmatic identifier indicating the reason for the condition's last transition. +Producers of specific condition types may define expected values and meanings for this field, +and whether the values are considered a guaranteed API. +The value should be a CamelCase string. +This field may not be empty.
    +
    true
    statusenum + status of the condition, one of True, False, Unknown.
    +
    + Enum: True, False, Unknown
    +
    true
    typestring + type of condition in CamelCase or in foo.example.com/CamelCase.
    +
    true
    observedGenerationinteger + observedGeneration represents the .metadata.generation that the condition was set based upon. +For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date +with respect to the current state of the instance.
    +
    + Format: int64
    + Minimum: 0
    +
    false
    diff --git a/docs/data/window_example.yaml b/docs/data/window_example.yaml index d8b4324..648e0f3 100644 --- a/docs/data/window_example.yaml +++ b/docs/data/window_example.yaml @@ -3,40 +3,40 @@ path: spec.namespaceSelectors description: Select development namespaces by label. Multiple selectors use OR; an empty selector selects every eligible namespace. Protected infrastructure namespaces are always excluded. guide: /docs/guides/schedules - reference: /docs/reference/api#changewindowspec + reference: /docs/reference/api#changewindowspecnamespaceselectorsindex - key: schedules title: Define the window path: spec.schedules description: Start at 19:00 each weekday in Europe/Zurich and stay active for 12 hours. Add more schedules when you need multiple periods; overlapping windows merge. guide: /docs/guides/schedules#cron-and-time-zones - reference: /docs/reference/api#schedule + reference: /docs/reference/api#changewindowspecschedulesindex - key: targets title: Select the workloads path: spec.targets description: Give a name to Deployments labeled downscaling=enabled. Actions, denials, and warnings refer to that name. Node admission targets can instead select Node labels directly. guide: /docs/guides/schedules#selection-over-time - reference: /docs/reference/api#target + reference: /docs/reference/api#changewindowspectargetsindex - key: serviceAccount title: Give actions an identity path: spec.serviceAccount description: Apply changes as this ServiceAccount, using its Kubernetes permissions. Create the account and grant the executor RBAC before applying this example. Admission-only windows can omit it. guide: /docs/guides/security#executor-permissions - reference: /docs/reference/api#serviceaccountreference + reference: /docs/reference/api#changewindowspecserviceaccount - key: actions title: Downscale, then restore path: spec.actions description: Capture the original replica count, apply zero through /scale at the start, then restore the captured count at the end. The start patch explicitly takes field ownership with force; review that choice for your workloads. guide: /docs/guides/actions#downscale-and-restore - reference: /docs/reference/api#resourceaction + reference: /docs/reference/api#changewindowspecactionsindex - key: admission title: Protect changes during maintenance path: spec.admission description: Block replica updates by members of the developers group while the window is active. CEL checks the actual Deployment or Scale payload. Other updates remain allowed by this entry, and the controller can execute its own scheduled actions. guide: /docs/guides/admission#match-conditions - reference: /docs/reference/api#admissionrule + reference: /docs/reference/api#changewindowspecadmissionindex - key: config title: Warn users in advance path: spec.config.admission.warnings description: Return a warning on matching writes in the hour before maintenance starts. This warning has no audience filter, so it applies to every caller. Each warning can also define its own audience and CEL conditions. guide: /docs/guides/admission#warning-periods - reference: /docs/reference/api#admissionwarning + reference: /docs/reference/api#changewindowspecconfigadmissionwarningsindex diff --git a/docs/hugo.toml b/docs/hugo.toml index 881f64c..3cdfba8 100644 --- a/docs/hugo.toml +++ b/docs/hugo.toml @@ -102,10 +102,10 @@ publishDir = '../dist/docs' target = "static" [[module.mounts]] -source = "../examples" +source = "../playground/manifests/examples" target = "assets/examples" [[module.mounts]] -source = "../examples" +source = "../playground/manifests/examples" target = "static/examples" [[module.mounts]] source = "../charts/change-windows/crds" diff --git a/docs/tests/home.spec.js b/docs/tests/home.spec.js index f53da03..dfad555 100644 --- a/docs/tests/home.spec.js +++ b/docs/tests/home.spec.js @@ -1,7 +1,7 @@ import { test, expect } from '@playwright/test'; import { readFile } from 'node:fs/promises'; -const source = await readFile(new URL('../../examples/quickstart.yaml', import.meta.url), 'utf8'); +const source = await readFile(new URL('../../playground/manifests/examples/quickstart.yaml', import.meta.url), 'utf8'); const guides = { namespaceSelectors: 'schedules/', @@ -71,7 +71,7 @@ test('explore YAML, copy the complete manifest, and follow matching references', expect(download.ok()).toBeTruthy(); expect(await download.text()).toBe(source); expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBeTruthy(); - const reference = isMobile ? 'changewindowspec' : 'admissionwarning'; + const reference = isMobile ? 'changewindowspecnamespaceselectorsindex' : 'changewindowspecconfigadmissionwarningsindex'; await example.locator('[data-window-help]:visible').getByRole('link', { name: 'API reference' }).click(); await expect(page).toHaveURL(new RegExp(`/schedule-actions/docs/reference/api/#${reference}$`)); await expect(page.locator(`#${reference}`)).toBeVisible(); diff --git a/e2e/after_hours_test.go b/e2e/after_hours_test.go index 1eff82e..4a09cb7 100644 --- a/e2e/after_hours_test.go +++ b/e2e/after_hours_test.go @@ -39,7 +39,7 @@ func TestAfterHoursExample(t *testing.T) { } createMatching(t, admin, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: ns, Labels: labels}}) } - data, err := os.ReadFile("../examples/after-hours-executor-rbac.yaml") + data, err := os.ReadFile("../playground/manifests/examples/after-hours-executor-rbac.yaml") if err != nil { t.Fatal(err) } @@ -74,7 +74,7 @@ func TestAfterHoursExample(t *testing.T) { createMatching(t, admin, other) stateful := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: "all-statefulsets", Namespace: "after-hours"}, Spec: appsv1.StatefulSetSpec{ServiceName: "example", Replicas: ptr.To(int32(3)), Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "stateful"}}, Template: corev1.PodTemplateSpec{ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "stateful"}}, Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "pause", Image: "registry.k8s.io/pause:3.10"}}}}}} createMatching(t, admin, stateful) - data, err = os.ReadFile("../examples/after-hours.yaml") + data, err = os.ReadFile("../playground/manifests/examples/after-hours.yaml") if err != nil { t.Fatal(err) } diff --git a/e2e/matching_test.go b/e2e/matching_test.go index ecf2dae..34c1120 100644 --- a/e2e/matching_test.go +++ b/e2e/matching_test.go @@ -289,7 +289,7 @@ func TestNodePoolExample(t *testing.T) { } createMatching(t, admin, &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: "node-window-writer"}, Rules: []rbacv1.PolicyRule{{APIGroups: []string{""}, Resources: []string{"nodes"}, ResourceNames: []string{"window-workers", "window-other"}, Verbs: []string{"get", "update", "patch", "delete"}}}}) createMatching(t, admin, &rbacv1.ClusterRoleBinding{ObjectMeta: metav1.ObjectMeta{Name: "node-window-writer"}, RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: "node-window-writer"}, Subjects: []rbacv1.Subject{{Kind: "User", Name: "node-operator", APIGroup: rbacv1.GroupName}, {Kind: "Group", Name: "platform-developers", APIGroup: rbacv1.GroupName}, {Kind: "User", Name: "unrelated-node-user", APIGroup: rbacv1.GroupName}, {Kind: "ServiceAccount", Namespace: "platform", Name: "deployer"}}}) - data, err := os.ReadFile("../examples/node-pool-maintenance.yaml") + data, err := os.ReadFile("../playground/manifests/examples/node-pool-maintenance.yaml") if err != nil { t.Fatal(err) } diff --git a/e2e/wildcard_test.go b/e2e/wildcard_test.go index f37cf2b..e4a0cc0 100644 --- a/e2e/wildcard_test.go +++ b/e2e/wildcard_test.go @@ -48,7 +48,7 @@ func TestFreezeExceptPodsExample(t *testing.T) { createMatching(t, admin, dep) config := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: "existing", Namespace: "freeze-all"}, Data: map[string]string{"value": "original"}} createMatching(t, admin, config) - data, err := os.ReadFile("../examples/freeze-except-pods.yaml") + data, err := os.ReadFile("../playground/manifests/examples/freeze-except-pods.yaml") if err != nil { t.Fatal(err) } diff --git a/go.mod b/go.mod index 175f12b..753d399 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/projectcapsule/capsule v0.14.6 github.com/prometheus/client_golang v1.24.1 github.com/robfig/cron/v3 v3.0.1 - golang.org/x/net v0.57.0 k8s.io/api v0.36.3 + k8s.io/apiextensions-apiserver v0.36.3 k8s.io/apimachinery v0.36.3 k8s.io/client-go v0.36.3 k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 @@ -75,6 +75,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.47.0 // indirect @@ -88,7 +89,6 @@ require ( 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 - k8s.io/apiextensions-apiserver v0.36.3 // indirect k8s.io/apiserver v0.36.3 // indirect k8s.io/component-base v0.36.3 // indirect k8s.io/klog/v2 v2.140.0 // indirect diff --git a/hack/docs/docs_test.go b/hack/docs/docs_test.go deleted file mode 100644 index 3f0f6b3..0000000 --- a/hack/docs/docs_test.go +++ /dev/null @@ -1,262 +0,0 @@ -//go:build docs - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package docs_test - -import ( - "bytes" - "encoding/json" - "net/url" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "golang.org/x/net/html" - "sigs.k8s.io/yaml" - - api "github.com/projectcapsule/schedule-action-controller/api/v1alpha1" - "github.com/projectcapsule/schedule-action-controller/internal/policy" - "github.com/projectcapsule/schedule-action-controller/internal/testutil" -) - -func TestSiteBuild(t *testing.T) { - root, err := filepath.Abs("../..") - if err != nil { - t.Fatal(err) - } - for _, prefix := range []string{"/", "/scheduled-actions/"} { - t.Run(prefix, func(t *testing.T) { - destination := t.TempDir() - cmd := exec.Command("hugo", "--source", "docs", "--destination", destination, - "--baseURL", "https://change-windows.addons.projectcapsule.dev"+prefix, "--minify", "--panicOnWarning") - cmd.Dir = root - cmd.Env = append(os.Environ(), "PATH="+filepath.Join(root, "docs/node_modules/.bin")+string(os.PathListSeparator)+os.Getenv("PATH")) - if output, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("Hugo build failed: %v\n%s", err, output) - } - type page struct { - ids map[string]bool - descriptions map[string]string - links []string - manifest string - parts int - } - pages := map[string]page{} - err := filepath.WalkDir(destination, func(file string, entry os.DirEntry, err error) error { - if err != nil { - return err - } - if entry.IsDir() || filepath.Ext(file) != ".html" { - return nil - } - data, err := os.ReadFile(file) - if err != nil { - return err - } - doc, err := html.Parse(bytes.NewReader(data)) - if err != nil { - return err - } - p := page{ids: map[string]bool{}, descriptions: map[string]string{}} - var visit func(*html.Node) - visit = func(node *html.Node) { - if node.Type == html.ElementNode && node.Data == "meta" { - var key, content string - for _, attr := range node.Attr { - switch attr.Key { - case "name", "property": - key = attr.Val - case "content": - content = attr.Val - } - } - if key == "description" || key == "og:description" || key == "twitter:description" { - p.descriptions[key] = content - } - } - for _, attr := range node.Attr { - switch attr.Key { - case "data-window-part": - p.parts++ - case "data-window-source": - var content strings.Builder - var collect func(*html.Node) - collect = func(n *html.Node) { - if n.Type == html.TextNode { - content.WriteString(n.Data) - } - for child := n.FirstChild; child != nil; child = child.NextSibling { - collect(child) - } - } - collect(node) - p.manifest = content.String() - case "id": - if p.ids[attr.Val] { - t.Errorf("duplicate anchor %s in %s", attr.Val, file) - } - p.ids[attr.Val] = true - case "src", "href": - p.links = append(p.links, attr.Val) - } - } - for child := node.FirstChild; child != nil; child = child.NextSibling { - visit(child) - } - } - visit(doc) - rel, err := filepath.Rel(destination, file) - if err != nil { - return err - } - pages[filepath.ToSlash(rel)] = p - return nil - }) - if err != nil { - t.Fatal(err) - } - if len(pages) < 16 { - t.Fatalf("expected a multi-page site, got %d pages", len(pages)) - } - // All three SEO surfaces must use explicit descriptions or the default, - // without deriving summaries from rendered code blocks and shortcodes. - for file, expected := range map[string]string{ - "docs/guides/actions/index.html": "Change Windows documentation: schedule maintenance, protect planned work, and automate resource changes in Kubernetes.", - "docs/guides/schedules/index.html": "Select namespaces and resources for ChangeWindows, understand protected namespaces, and configure schedules, time zones, and overlapping windows.", - "docs/reference/api/index.html": "Generated reference for the ChangeWindow Kubernetes API.", - } { - for _, key := range []string{"description", "og:description", "twitter:description"} { - if got := pages[file].descriptions[key]; got != expected { - t.Errorf("%s in %s: got %q, want %q", key, file, got, expected) - } - } - } - source, err := os.ReadFile(filepath.Join(root, "examples/quickstart.yaml")) - if err != nil { - t.Fatal(err) - } - if home := pages["index.html"]; home.parts != 7 || home.manifest != string(source) { - t.Errorf("homepage must expose seven linked blocks and exact copyable YAML: parts=%d source matches=%t", home.parts, home.manifest == string(source)) - } - checkLink := func(source, link string) { - t.Helper() - base, _ := url.Parse("https://change-windows.addons.projectcapsule.dev" + prefix + strings.TrimSuffix(source, "index.html")) - u, err := url.Parse(link) - if err != nil { - t.Errorf("invalid link %q: %v", link, err) - return - } - u = base.ResolveReference(u) - if u.Scheme != "https" || u.Host != base.Host { - return - } - if !strings.HasPrefix(u.Path, prefix) { - t.Errorf("link escapes hosting prefix: %s in %s", link, source) - return - } - asset := strings.TrimPrefix(u.Path, prefix) - if strings.HasSuffix(u.Path, "/") { - asset += "index.html" - } - if _, err := os.Stat(filepath.Join(destination, filepath.FromSlash(asset))); err != nil { - t.Errorf("missing local target %s in %s: %v", link, source, err) - } - if u.Fragment != "" && strings.HasSuffix(asset, ".html") && !pages[asset].ids[u.Fragment] { - t.Errorf("broken fragment %s in %s", link, source) - } - } - for file, p := range pages { - for _, link := range p.links { - checkLink(file, link) - } - } - for file, anchors := range map[string][]string{ - "index.html": {"window-example-title", "window-help-admission", "window-help-config"}, - "docs/getting-started/installation/index.html": {"first-maintenance-window"}, - "docs/guides/actions/index.html": {"downscale-and-restore", "rollout-restart"}, - "docs/guides/admission/index.html": {"warning-periods", "audiences", "match-conditions"}, - "docs/examples/node-pool/index.html": {"complete-example", "who-is-affected", "what-is-protected", "try-the-policy"}, - "docs/examples/conditional-changes/index.html": {"complete-example", "expected-behavior"}, - "docs/development/contributing/index.html": {"tests-and-validation", "work-on-the-documentation"}, - "docs/reference/api/index.html": {"changewindowspec", "changewindowstatus", "admissionwarning"}, - } { - for _, anchor := range anchors { - if !pages[file].ids[anchor] { - t.Errorf("missing section %s in %s", anchor, file) - } - } - } - var search []struct{ Permalink, Title, Content string } - data, err := os.ReadFile(filepath.Join(destination, "search-index.json")) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(data, &search); err != nil { - t.Fatal(err) - } - if len(search) < 10 { - t.Fatalf("missing guides from search: %d entries", len(search)) - } - foundAPI := false - for _, result := range search { - checkLink("index.html", result.Permalink) - if result.Title == "API reference" && strings.Contains(result.Content, "ChangeWindowSpec") { - foundAPI = true - } - } - if !foundAPI { - t.Error("generated API types are not searchable") - } - for _, sample := range []string{"maintenance-only.yaml", "addons_v1alpha1_changewindow.yaml", "executor-rbac.yaml", "node-pool-maintenance.yaml", "conditional-maintenance.yaml", "quickstart.yaml", "freeze-except-pods.yaml", "after-hours.yaml", "after-hours-executor-rbac.yaml"} { - original, err := os.ReadFile(filepath.Join(root, "examples", sample)) - if err != nil { - t.Fatal(err) - } - published, err := os.ReadFile(filepath.Join(destination, "examples", sample)) - if err != nil || !bytes.Equal(original, published) { - t.Errorf("download diverges from executable example %s: %v", sample, err) - } - } - }) - } -} - -func TestChangeWindowExamples(t *testing.T) { - files, err := filepath.Glob("../../examples/*.yaml") - if err != nil || len(files) == 0 { - t.Fatalf("no examples found: %v", err) - } - count := 0 - for _, file := range files { - data, err := os.ReadFile(file) - if err != nil { - t.Fatal(err) - } - var kind struct { - Kind string `json:"kind"` - } - if err := yaml.Unmarshal(data, &kind); err != nil { - t.Fatal(err) - } - if kind.Kind != "ChangeWindow" { - continue - } - count++ - t.Run(filepath.Base(file), func(t *testing.T) { - var action api.ChangeWindow - if err := yaml.UnmarshalStrict(data, &action); err != nil { - t.Fatal(err) - } - if _, err := policy.Compile(&action, testutil.Mapper()); err != nil { - t.Fatalf("documented example fails policy validation: %v", err) - } - }) - } - if count < 7 { - t.Fatal("expected homepage, Node, conditional, admission-only and resource-action examples") - } -} diff --git a/hack/playground/headlamp-values.yaml b/hack/playground/headlamp-values.yaml deleted file mode 100644 index 1d49aaf..0000000 --- a/hack/playground/headlamp-values.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Only for the disposable playground, reached through localhost or private Tailscale. -# All visitors share the limited demo identity. Never expose this on a public URL. -fullnameOverride: headlamp -config: - unsafeUseServiceAccountToken: true - pluginsDir: /headlamp/plugins -clusterRoleBinding: - create: false -serviceAccount: - name: headlamp -volumes: - - name: local-plugin - hostPath: - path: /opt/change-windows-plugins - type: Directory -volumeMounts: - - name: local-plugin - mountPath: /headlamp/plugins - readOnly: true diff --git a/hack/prek-builtins.toml b/hack/prek-builtins.toml deleted file mode 100644 index af93145..0000000 --- a/hack/prek-builtins.toml +++ /dev/null @@ -1,16 +0,0 @@ -# Native checks invoked by task lint:files and task lint:yaml. -minimum_prek_version = "0.5.5" -default_stages = ["pre-commit"] -exclude = '^(\.devbox/|\.task/|bin/|dist/|artifacts/|builds/)' - -[[repos]] -repo = "builtin" -hooks = [ - { id = "check-executables-have-shebangs", groups = ["files"], priority = 10 }, - { id = "end-of-file-fixer", args = ["--check"], groups = ["files"], priority = 10 }, - { id = "trailing-whitespace", args = ["--check"], groups = ["files"], priority = 10 }, - { id = "check-merge-conflict", groups = ["files"], priority = 10 }, - { id = "check-json", groups = ["files"], priority = 10 }, - { id = "check-toml", groups = ["files"], priority = 10 }, - { id = "check-yaml", args = ["--allow-multiple-documents"], exclude = '^charts/[^/]+/templates/', groups = ["files"], priority = 10 }, -] diff --git a/hack/tooling/dev_deploy_test.go b/hack/tooling/dev_deploy_test.go deleted file mode 100644 index 2af1722..0000000 --- a/hack/tooling/dev_deploy_test.go +++ /dev/null @@ -1,246 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - appsv1 "k8s.io/api/apps/v1" - corev1 "k8s.io/api/core/v1" - rbacv1 "k8s.io/api/rbac/v1" - "sigs.k8s.io/yaml" -) - -func deploymentFixture(t *testing.T) (string, []string) { - t.Helper() - dir, env := fixture(t) - for _, name := range []string{"kubectl", "kind"} { - filename := filepath.Join(dir, "tools", name) - write(t, filename, `#!/usr/bin/env bash -set -euo pipefail -name="${0##*/}" -printf '%s' "$name" >> "$TOOL_LOG" -printf '\t%s' "$@" >> "$TOOL_LOG" -printf '\n' >> "$TOOL_LOG" -if [[ "${FAIL_TOOL:-}" == "$name" ]]; then exit 37; fi -if [[ "$name" == kind ]]; then - if [[ "$1" == load && "${FAIL_STAGE:-}" == load ]]; then exit 37; fi - if [[ "$1" == get ]]; then echo 'private fixture kubeconfig'; fi -fi -if [[ "$name" == kubectl ]]; then - case "$*" in - *'get crd'*) [[ "${FAIL_STAGE:-}" != certificates ]] ;; - *'config view'*) - if [[ "$1" == --context && "${FAIL_STAGE:-}" == context ]]; then echo wrong-cluster; else echo expected-cluster; fi ;; - *'get nodes'*) printf '%s\n' "${NODE_ARCH:-arm64}" ;; - *'rollout status'*) [[ "${FAIL_STAGE:-}" != rollout ]] ;; - *'wait certificate'*) [[ "${FAIL_STAGE:-}" != certificate-ready ]] ;; - *'wait validatingwebhookconfiguration'*) [[ "${FAIL_STAGE:-}" != injection ]] ;; - *'create --dry-run=server'*) [[ "${FAIL_STAGE:-}" != probe ]] ;; - esac -fi -`) - if err := os.Chmod(filename, 0o755); err != nil { - t.Fatal(err) - } - } - return dir, env -} - -func TestDevDeploy(t *testing.T) { - for _, mode := range []string{"kind", "registry"} { - t.Run(mode, func(t *testing.T) { - dir, env := deploymentFixture(t) - values := filepath.Join(dir, "values with spaces.yaml") - write(t, values, "manager:\n resources: {}\n") - args := []string{"dev:deploy", "DEV_CONTEXT=development", "DEV_KIND_CLUSTER=isolated", "DEV_IMAGE_MODE=" + mode, "IMG=localhost:5000/team/controller:dev", "DEV_NAMESPACE=dev-system", "DEV_RELEASE=custom", "DEV_HELM_VALUES=" + values, "DEV_TIMEOUT=1"} - successful(t, dir, env, "task", args...) - log := logContents(t, dir) - wants := []string{"helm\t--kube-context\tdevelopment\tupgrade\t--install\tcustom\tcharts/change-windows", "--namespace\tdev-system", "--reset-values", "--skip-crds", "-f\t" + values, "crds.inline=true", "crds.exclusive=false", "webhooks.service.url=", "webhooks.service.caBundle=", "webhooks.certificate.generate.enabled=true", "rollout\tstatus\tdeployment", "--for=condition=Ready", "--for=jsonpath={.webhooks[1].clientConfig.caBundle}", "create\t--dry-run=server"} - if mode == "kind" { - wants = append(wants, "--push=false", "--platform=linux/arm64", "kind\tload\timage-archive\tdist/controller.tar\t--name\tisolated", "manager.image.pullPolicy=Never", "manager.hostUsers=true", "manager.image.reference=localhost:5000/team/controller:dev-") - } else { - wants = append(wants, "--push=true", "manager.image.reference=localhost:5000/team/controller@sha256:"+strings.Repeat("0", 64)) - if strings.Contains(log, "kind\t") { - t.Fatal("registry mode used Kind") - } - } - for _, want := range wants { - if !strings.Contains(log, want) { - t.Errorf("missing %q in %s", want, log) - } - } - if strings.Index(log, "ko\tbuild") > strings.Index(log, "helm\t") { - t.Fatal("Helm ran before the image was built") - } - if strings.Contains(log, "use-context") || strings.Contains(log, "docker\t") { - t.Fatal("deployment changed current context or built through Docker") - } - if mode == "kind" { - write(t, filepath.Join(dir, "tools.log"), "") - successful(t, dir, env, "task", args...) - first := strings.Split(strings.Split(log, "manager.image.reference=")[1], "\t")[0] - second := strings.Split(strings.Split(logContents(t, dir), "manager.image.reference=")[1], "\t")[0] - if first == second { - t.Fatal("repeat deployment reused a mutable local tag") - } - } - }) - } -} - -func TestDevDeployFailures(t *testing.T) { - for _, tc := range []struct { - name string - env, args []string - beforeHelm bool - }{ - {name: "missing context", args: []string{"DEV_CONTEXT="}, beforeHelm: true}, - {name: "invalid mode", args: []string{"DEV_IMAGE_MODE=invalid"}, beforeHelm: true}, - {name: "missing Kind cluster", args: []string{"DEV_KIND_CLUSTER="}, beforeHelm: true}, - {name: "missing values", args: []string{"DEV_HELM_VALUES=missing.yaml"}, beforeHelm: true}, - {name: "invalid timeout", args: []string{"DEV_TIMEOUT=oops"}, beforeHelm: true}, - {name: "missing cert-manager", env: []string{"FAIL_STAGE=certificates"}, beforeHelm: true}, - {name: "wrong context", env: []string{"FAIL_STAGE=context"}, beforeHelm: true}, - {name: "mixed architectures", env: []string{"NODE_ARCH=arm64\namd64"}, beforeHelm: true}, - {name: "wrong platform", args: []string{"IMAGE_PLATFORM=linux/amd64"}, beforeHelm: true}, - {name: "failed build", env: []string{"FAIL_TOOL=ko"}, beforeHelm: true}, - {name: "failed load", env: []string{"FAIL_STAGE=load"}, beforeHelm: true}, - {name: "failed Helm", env: []string{"FAIL_TOOL=helm"}}, - {name: "failed rollout", env: []string{"FAIL_STAGE=rollout"}}, - {name: "failed certificate", env: []string{"FAIL_STAGE=certificate-ready"}}, - {name: "failed injection", env: []string{"FAIL_STAGE=injection"}}, - {name: "failed admission probe", env: []string{"FAIL_STAGE=probe"}}, - } { - t.Run(tc.name, func(t *testing.T) { - dir, env := deploymentFixture(t) - args := append([]string{"dev:deploy", "DEV_CONTEXT=development", "DEV_KIND_CLUSTER=isolated", "DEV_TIMEOUT=1"}, tc.args...) - out, err := command(t, dir, append(env, tc.env...), "task", args...) - if err == nil || strings.Contains(out, "controller and admission are ready") { - t.Fatalf("failure accepted: %v\n%s", err, out) - } - if tc.beforeHelm && strings.Contains(logContents(t, dir), "helm\t") { - t.Fatal("Helm ran after a failed prerequisite") - } - }) - } -} - -func TestDevDeployRejectsWrongDigest(t *testing.T) { - dir, env := deploymentFixture(t) - // A successful publisher must still report the exact repository that was requested. - ko := filepath.Join(dir, "tools/ko") - write(t, ko, "#!/usr/bin/env bash\nprintf 'other/repository@sha256:%064d\\n' 0\n") - if err := os.Chmod(ko, 0o755); err != nil { - t.Fatal(err) - } - out, err := command(t, dir, env, "task", "dev:deploy", "DEV_CONTEXT=development", "DEV_IMAGE_MODE=registry", "IMG=localhost:5000/team/controller:dev") - if err == nil || !strings.Contains(out, "expected immutable image reference") { - t.Fatalf("wrong published reference accepted: %v %s", err, out) - } - if strings.Contains(logContents(t, dir), "helm\t") { - t.Fatal("deployed an unverified image") - } -} - -func TestDevDeployChartContract(t *testing.T) { - root := repository(t) - image := "registry.example.com/team/controller@sha256:" + strings.Repeat("a", 64) - out := successful(t, root, nil, "helm", "template", "custom", "charts/change-windows", "--namespace", "dev-system", "--set", "crds.inline=true", "--set", "manager.replicaCount=2", "--set", "manager.image.reference="+image, "--set", "serviceAccount.name=custom-controller") - var deployment appsv1.Deployment - var role rbacv1.ClusterRole - var serviceAccount corev1.ServiceAccount - bindings := 0 - for _, document := range strings.Split(out, "\n---\n") { - var meta struct { - Kind string `json:"kind"` - } - if err := yaml.Unmarshal([]byte(document), &meta); err != nil { - t.Fatal(err) - } - switch meta.Kind { - case "Deployment": - if err := yaml.UnmarshalStrict([]byte(document), &deployment); err != nil { - t.Fatal(err) - } - case "ClusterRole": - if err := yaml.Unmarshal([]byte(document), &role); err != nil { - t.Fatal(err) - } - case "ServiceAccount": - if err := yaml.UnmarshalStrict([]byte(document), &serviceAccount); err != nil { - t.Fatal(err) - } - case "RoleBinding", "ClusterRoleBinding": - var binding rbacv1.ClusterRoleBinding - if err := yaml.Unmarshal([]byte(document), &binding); err != nil { - t.Fatal(err) - } - if len(binding.Subjects) != 1 || binding.Subjects[0].Name != "custom-controller" || binding.Subjects[0].Namespace != "dev-system" { - t.Fatalf("incorrect controller binding: %+v", binding.Subjects) - } - bindings++ - } - } - if deployment.Spec.Replicas == nil || *deployment.Spec.Replicas != 2 || bindings != 2 || serviceAccount.Name != "custom-controller" || serviceAccount.Namespace != "dev-system" { - t.Fatal("chart ignored replicas or service-account settings") - } - pod := deployment.Spec.Template.Spec - container := pod.Containers[0] - if pod.ServiceAccountName != "custom-controller" || container.Image != image || len(container.Command) != 0 { - t.Fatal("deployment lost its account, immutable image, or ko entrypoint") - } - for _, probe := range []*corev1.Probe{container.ReadinessProbe, container.LivenessProbe} { - if probe == nil || probe.HTTPGet.Port.IntVal != 8081 { - t.Fatal("probe does not match the controller's listener") - } - } - foundNamespace := false - for _, env := range container.Env { - if env.Name == "POD_NAMESPACE" && env.ValueFrom != nil && env.ValueFrom.FieldRef.FieldPath == "metadata.namespace" { - foundNamespace = true - } - } - if !foundNamespace { - t.Fatal("controller namespace was not configured") - } - for _, arg := range container.Args { - if !strings.HasPrefix(arg, "--") { - t.Fatalf("malformed argument %q", arg) - } - } - data, err := os.ReadFile(filepath.Join(root, "charts/change-windows/files/manager-role.yaml")) - if err != nil { - t.Fatal(err) - } - var generated rbacv1.ClusterRole - if err := yaml.Unmarshal(data, &generated); err != nil { - t.Fatal(err) - } - for _, want := range generated.Rules { - found := false - for _, actual := range role.Rules { - if reflect.DeepEqual(actual, want) { - found = true - break - } - } - if !found { - t.Errorf("controller RBAC missing generated rule %+v", want) - } - } - for _, rule := range role.Rules { - for _, resource := range rule.Resources { - if resource == "secrets" || resource == "serviceaccounts" { - t.Fatal("controller has unscoped Secret access or service-account impersonation") - } - } - } -} diff --git a/hack/tooling/dev_setup_test.go b/hack/tooling/dev_setup_test.go deleted file mode 100644 index 3d76284..0000000 --- a/hack/tooling/dev_setup_test.go +++ /dev/null @@ -1,228 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "bytes" - "crypto/x509" - "encoding/base64" - "encoding/pem" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "syscall" - "testing" - "time" - - admissionv1 "k8s.io/api/admissionregistration/v1" - "sigs.k8s.io/yaml" -) - -func TestDevSetup(t *testing.T) { - for _, host := range []string{"192.0.2.10", "host.docker.internal", "2001:db8::10"} { - t.Run(host, func(t *testing.T) { - dir, env := fixture(t) - certDir := filepath.Join(dir, "certs with spaces") - args := []string{"dev:setup", "DEV_CONTEXT=development", "LAPTOP_HOST_IP=" + host, "DEV_NAMESPACE=dev-system", "DEV_CERT_DIR=" + certDir} - successful(t, dir, env, "task", args...) - certPEM, err := os.ReadFile(filepath.Join(certDir, "tls.crt")) - if err != nil { - t.Fatal(err) - } - block, _ := pem.Decode(certPEM) - if block == nil { - t.Fatal("missing PEM certificate") - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - t.Fatal(err) - } - if err := cert.VerifyHostname(host); err != nil { - t.Fatal(err) - } - key, err := os.Stat(filepath.Join(certDir, "tls.key")) - if err != nil || key.Mode().Perm() != 0o600 { - t.Fatalf("private key permissions: %v, %v", key, err) - } - urlHost := host - if strings.Contains(host, ":") { - urlHost = "[" + host + "]" - } - log := logContents(t, dir) - for _, want := range []string{ - "helm\t--kube-context\tdevelopment\tupgrade\t--install\tchange-windows\tcharts/change-windows", - "--namespace\tdev-system", "--skip-crds", "crds.inline=true", "crds.keep=true", - "crds.exclusive=true", "webhooks.exclusive=true", "webhooks.certificate.generate.enabled=false", - "webhooks.service.url=https://" + urlHost + ":9443", - "webhooks.service.caBundle=" + base64.StdEncoding.EncodeToString(certPEM), - "kubectl\t--context\tdevelopment\twait\t--for=condition=Established", - } { - if !strings.Contains(log, want) { - t.Errorf("missing argument %q", want) - } - } - if strings.Contains(log, "create\tsecret") || strings.Contains(log, "use-context") { - t.Fatal("setup changed credentials or current context") - } - successful(t, dir, env, "task", args...) - again, _ := os.ReadFile(filepath.Join(certDir, "tls.crt")) - if !bytes.Equal(again, certPEM) { - t.Fatal("repeat setup rotated a valid certificate") - } - successful(t, dir, env, "task", append(args, "DEV_HOST=192.0.2.11")...) - changed, _ := os.ReadFile(filepath.Join(certDir, "tls.crt")) - if bytes.Equal(changed, certPEM) { - t.Fatal("changing the host retained an invalid certificate") - } - successful(t, dir, env, "task", "dev:run", "DEV_CONTEXT=development", "DEV_NAMESPACE=dev-system", "DEV_CERT_DIR="+certDir) - if !strings.Contains(logContents(t, dir), "manager\t--system-namespace=dev-system\t--webhook-cert-path="+certDir) { - t.Fatal("run did not use setup's namespace and certificates") - } - files, _ := filepath.Glob(filepath.Join(certDir, "run.*")) - if len(files) != 0 { - t.Fatal("temporary kubeconfig was retained") - } - }) - } -} - -func TestDevRunTermination(t *testing.T) { - dir, env := fixture(t) - certDir := filepath.Join(dir, "certs") - write(t, filepath.Join(certDir, "tls.crt"), "certificate fixture") - write(t, filepath.Join(certDir, "tls.key"), "key fixture") - pidFile := filepath.Join(dir, "manager.pid") - cmd := exec.Command("task", "dev:run", "DEV_CONTEXT=development", "DEV_CERT_DIR="+certDir) - // A terminal delivers Ctrl-C/termination to the foreground process group. - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - cmd.Dir = dir - cmd.Env = append(os.Environ(), append(env, "WAIT_MANAGER=true", "MANAGER_PID_FILE="+pidFile)...) - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }) - deadline := time.Now().Add(10 * time.Second) - var pid int - for time.Now().Before(deadline) { - data, err := os.ReadFile(pidFile) - if err == nil { - pid, _ = strconv.Atoi(strings.TrimSpace(string(data))) - if pid > 0 { - break - } - } - time.Sleep(20 * time.Millisecond) - } - if pid == 0 { - t.Fatal("controller did not start") - } - t.Cleanup(func() { _ = syscall.Kill(pid, syscall.SIGKILL) }) - if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { - t.Fatal(err) - } - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatal("Task did not terminate the controller") - } - if err := syscall.Kill(pid, 0); err != syscall.ESRCH { - t.Fatalf("controller still running after stopping Task: %v", err) - } - files, err := filepath.Glob(filepath.Join(certDir, "run.*")) - if err != nil || len(files) != 0 { - t.Fatalf("temporary binary or credentials retained: %v, %v", files, err) - } -} - -func TestDevSetupFailures(t *testing.T) { - for _, tc := range []struct{ name, context, host, fail string }{ - {name: "missing context", host: "192.0.2.1"}, - {name: "missing host", context: "development"}, - {name: "URL instead of host", context: "development", host: "https://example.com:9443"}, - {name: "loopback", context: "development", host: "127.0.0.1"}, - {name: "extension injection", context: "development", host: "example.com\nDNS:other"}, - {name: "invalid IPv4", context: "development", host: "999.999.999.999"}, - {name: "inaccessible cluster", context: "development", host: "192.0.2.1", fail: "kubectl"}, - {name: "failed Helm installation", context: "development", host: "192.0.2.1", fail: "helm"}, - } { - t.Run(tc.name, func(t *testing.T) { - dir, env := fixture(t) - out, err := command(t, dir, append(env, "FAIL_TOOL="+tc.fail), "task", "dev:setup", "DEV_CONTEXT="+tc.context, "DEV_HOST="+tc.host) - if err == nil || strings.Contains(out, "Admission points to") { - t.Fatalf("setup accepted invalid input or a failed tool: %v\n%s", err, out) - } - if tc.fail != "helm" && strings.Contains(logContents(t, dir), "helm\t") { - t.Fatal("Helm ran after failed preflight") - } - }) - } - dir, env := fixture(t) - if out, err := command(t, dir, env, "task", "dev:run", "DEV_CONTEXT=development"); err == nil || !strings.Contains(out, "Run task dev:setup first") { - t.Fatalf("run accepted missing certificates: %v\n%s", err, out) - } -} - -func TestDevSetupRendering(t *testing.T) { - root := repository(t) - for _, enabled := range []bool{false, true} { - args := []string{"template", "dev", "charts/change-windows", "--namespace", "dev-system", "--set", "crds.inline=true", "--set", "crds.exclusive=true", "--set", "webhooks.certificate.generate.enabled=false", "--set", "webhooks.service.url=https://192.0.2.10:9443", "--set", "webhooks.service.caBundle=dGVzdA=="} - if enabled { - args = append(args, "--set", "webhooks.exclusive=true") - } - out := successful(t, root, nil, "helm", args...) - webhooks, crds := 0, 0 - for _, document := range strings.Split(out, "\n---\n") { - var meta struct { - Kind string `json:"kind"` - } - if err := yaml.Unmarshal([]byte(document), &meta); err != nil { - t.Fatal(err) - } - switch meta.Kind { - case "CustomResourceDefinition": - crds++ - case "ValidatingWebhookConfiguration": - webhooks++ - var config admissionv1.ValidatingWebhookConfiguration - if err := yaml.UnmarshalStrict([]byte(document), &config); err != nil { - t.Fatal(err) - } - if len(config.Webhooks) != 2 { - t.Fatal("expected both admission endpoints") - } - for i, w := range config.Webhooks { - endpoint := []string{"/validate-resources", "/validate-changewindow"}[i] - if w.ClientConfig.URL == nil || *w.ClientConfig.URL != "https://192.0.2.10:9443"+endpoint || w.ClientConfig.Service != nil || string(w.ClientConfig.CABundle) != "test" { - t.Fatalf("incorrect external webhook: %+v", w.ClientConfig) - } - if w.FailurePolicy == nil || *w.FailurePolicy != admissionv1.Fail { - t.Fatal("development webhook must fail closed") - } - if i == 0 { - if w.NamespaceSelector == nil || len(w.NamespaceSelector.MatchExpressions) != 1 { - t.Fatal("resource admission must use the default tenant namespace selector") - } - selector := w.NamespaceSelector.MatchExpressions[0] - if selector.Key != "capsule.clastix.io/tenant" || selector.Operator != "Exists" { - t.Fatalf("unexpected tenant namespace selector: %+v", selector) - } - } else if w.NamespaceSelector != nil { - t.Fatal("cluster-scoped ChangeWindow validation must not need namespace selection") - } - } - default: - t.Fatalf("unexpected in-cluster component %q", meta.Kind) - } - } - if crds != 1 || (webhooks == 1) != enabled { - t.Fatalf("unexpected CRDs/webhooks: %d/%d", crds, webhooks) - } - } -} diff --git a/hack/tooling/docs_test.go b/hack/tooling/docs_test.go deleted file mode 100644 index 49fba45..0000000 --- a/hack/tooling/docs_test.go +++ /dev/null @@ -1,159 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "sigs.k8s.io/yaml" -) - -func hugoFixture(t *testing.T) (string, []string) { - t.Helper() - dir, env := fixture(t) - copyFile(t, repository(t), dir, "docs/package.json") - write(t, filepath.Join(dir, "tools.log"), "") - write(t, filepath.Join(dir, "docs/content/docs/reference/api.md"), `--- -title: API reference -weight: 10 -description: Generated reference for the ChangeWindow Kubernetes API. ---- - - - -## Example -A generated type. -`) - return dir, env -} - -func TestAPIReferenceTasks(t *testing.T) { - dir, env := hugoFixture(t) - reference := filepath.Join(dir, "docs/content/docs/reference/api.md") - write(t, reference, "stale reference\n") - if out, err := command(t, dir, env, "task", "docs:check"); err == nil || !strings.Contains(out, "API reference is stale") { - t.Fatalf("stale reference was accepted: %v\n%s", err, out) - } - successful(t, dir, env, "task", "docs:generate") - successful(t, dir, env, "task", "docs:check") - before, err := os.ReadFile(reference) - if err != nil { - t.Fatal(err) - } - if out, err := command(t, dir, append(env, "FAIL_TOOL=go"), "task", "docs:generate"); err == nil { - t.Fatalf("failed generator succeeded: %s", out) - } - after, err := os.ReadFile(reference) - if err != nil || string(after) != string(before) { - t.Fatal("failed generation replaced the API reference") - } -} - -func TestDocumentationTaskCommands(t *testing.T) { - for _, task := range []string{"docs:serve", "docs:build", "docs:check", "docs:test:browser"} { - t.Run(task, func(t *testing.T) { - dir, env := hugoFixture(t) - successful(t, dir, env, "task", task) - log := logContents(t, dir) - if !strings.Contains(log, "npm\tci\t--ignore-scripts") { - t.Fatalf("Doks dependencies were not installed: %s", log) - } - if task != "docs:test:browser" && !strings.Contains(log, "crd-ref-docs\t") { - t.Fatalf("documentation task did not continue %s: %s", task, log) - } - if task == "docs:check" && !strings.Contains(log, "go\ttest") || - task == "docs:test:browser" && !strings.Contains(log, "npm\t--prefix\tdocs\trun\ttest:browser") || - task == "docs:build" && !strings.Contains(log, "npm\t--prefix\tdocs\trun\tbuild") || - task == "docs:serve" && !strings.Contains(log, "npm\t--prefix\tdocs\trun\tdev") { - t.Fatalf("documentation task did not run %s: %s", task, log) - } - }) - } -} - -func TestHugoVersionRequirementsAgree(t *testing.T) { - root := repository(t) - var config struct { - Packages map[string]string `json:"packages"` - } - data, err := os.ReadFile(filepath.Join(root, "devbox.json")) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(data, &config); err != nil { - t.Fatal(err) - } - version := config.Packages["hugo"] - for file, expected := range map[string]string{ - "docs/hugo.toml": "min = '" + version + "'", - } { - data, err := os.ReadFile(filepath.Join(root, file)) - if err != nil || !strings.Contains(string(data), expected) { - t.Fatalf("%s must match the Devbox Hugo pin %s: %v", file, version, err) - } - } -} - -func TestDocumentationTasksStopOnInstallFailure(t *testing.T) { - for _, task := range []string{"docs:serve", "docs:build", "docs:check", "docs:test:browser"} { - t.Run(task, func(t *testing.T) { - dir, env := hugoFixture(t) - env = append(env, "FAIL_TOOL=npm") - if out, err := command(t, dir, env, "task", task); err == nil { - t.Fatalf("failed dependency installation did not stop %s: %s", task, out) - } - log := logContents(t, dir) - if !strings.Contains(log, "npm\tci") || strings.Contains(log, "crd-ref-docs\t") || strings.Contains(log, "hugo\t") || strings.Contains(log, "go\ttest") { - t.Fatalf("unexpected work after installation failure: %s", log) - } - }) - } -} - -func TestDocumentationDeploymentUsesHTTPS(t *testing.T) { - data, err := os.ReadFile(filepath.Join(repository(t), ".github/workflows/docs.yml")) - if err != nil { - t.Fatal(err) - } - var workflow struct { - Env map[string]string - Jobs map[string]struct { - Env map[string]string - Steps []struct { - Run string - Env map[string]string - } - } - } - if err := yaml.Unmarshal(data, &workflow); err != nil { - t.Fatal(err) - } - job := workflow.Jobs["build"] - for _, step := range job.Steps { - if !strings.Contains(step.Run, "task docs:check docs:build") { - continue - } - dir, env := hugoFixture(t) - for _, values := range []map[string]string{workflow.Env, job.Env, step.Env} { - if value, exists := values["DOCS_BASE_URL"]; exists { - if !strings.HasPrefix(value, "https://") { - t.Fatalf("Pages may report HTTP: documentation build must use a known HTTPS base URL, got %q", value) - } - env = append(env, "DOCS_BASE_URL="+value) - } - } - successful(t, dir, env, "task", "docs:build") - if log := logContents(t, dir); !strings.Contains(log, "--baseURL\thttps://change-windows.addons.projectcapsule.dev/") { - t.Fatalf("published Hugo build must resolve its scripts and search index over HTTPS: %s", log) - } - return - } - t.Fatal("documentation build step not found") -} diff --git a/hack/tooling/headlamp_workflows_test.go b/hack/tooling/headlamp_workflows_test.go deleted file mode 100644 index 9bed7be..0000000 --- a/hack/tooling/headlamp_workflows_test.go +++ /dev/null @@ -1,147 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "encoding/json" - "os" - "path/filepath" - "regexp" - "slices" - "strings" - "testing" - - "go.yaml.in/yaml/v3" -) - -type headlampWorkflow struct { - On map[string]struct { - Tags []string `yaml:"tags"` - } `yaml:"on"` - Permissions map[string]string `yaml:"permissions"` - Jobs map[string]struct { - Needs []string `yaml:"needs"` - Permissions map[string]string `yaml:"permissions"` - Concurrency struct { - Group string `yaml:"group"` - Cancel bool `yaml:"cancel-in-progress"` - } `yaml:"concurrency"` - Steps []struct { - Uses string `yaml:"uses"` - With map[string]string `yaml:"with"` - } `yaml:"steps"` - } `yaml:"jobs"` -} - -func TestHeadlampWorkflowPublicationBoundary(t *testing.T) { - root := repository(t) - read := func(name string) headlampWorkflow { - t.Helper() - data, err := os.ReadFile(filepath.Join(root, ".github/workflows", name)) - if err != nil { - t.Fatal(err) - } - var workflow headlampWorkflow - if err := yaml.Unmarshal(data, &workflow); err != nil { - t.Fatal(err) - } - return workflow - } - pr, release := read("ci.yml"), read("releaser.yml") - if _, ok := pr.On["pull_request"]; !ok { - t.Fatal("Headlamp checks do not run on PRs") - } - if _, ok := pr.On["pull_request_target"]; ok { - t.Fatal("untrusted plugin code must not run under pull_request_target") - } - for _, job := range pr.Jobs { - for _, permission := range job.Permissions { - if permission == "write" { - t.Fatal("PR checks have write permissions") - } - } - for _, step := range job.Steps { - if strings.Contains(step.Uses, "headlamp-publish@") { - t.Fatal("PR workflow includes publication") - } - } - } - if pr.Permissions["contents"] != "read" || release.Permissions["contents"] != "read" || - len(release.On) != 1 || !slices.Equal(release.On["push"].Tags, []string{"v*"}) { - t.Fatal("release trigger or default token scope is incorrect") - } - publish := release.Jobs["headlamp-publish"] - if !slices.Equal(publish.Needs, []string{"release"}) || publish.Permissions["contents"] != "write" { - t.Fatal("plugin publication must wait for the controller release without rerunning PR checks") - } - if publish.Concurrency.Group != "headlamp-plugin-publication" || publish.Concurrency.Cancel { - t.Fatal("metadata branch writers must serialize across tags without cancellation") - } - node, err := os.ReadFile(filepath.Join(root, "headlamp/.node-version")) - if err != nil { - t.Fatal(err) - } - shared := regexp.MustCompile(`^projectcapsule/github-actions/headlamp-(test|publish)@([a-f0-9]{40})$`) - var revision string - for name, workflow := range map[string]headlampWorkflow{"pr": pr, "release": release} { - checks, publications := 0, 0 - for _, job := range workflow.Jobs { - for _, step := range job.Steps { - if !strings.Contains(step.Uses, "/headlamp-") { - continue - } - match := shared.FindStringSubmatch(step.Uses) - if match == nil { - t.Fatalf("shared action must be SHA pinned: %s", step.Uses) - } - if revision != "" && revision != match[2] { - t.Fatal("Headlamp actions use inconsistent shared revisions") - } - revision = match[2] - if step.With["build-root"] != "headlamp/" || step.With["node-version"] != strings.TrimSpace(string(node)) { - t.Fatal("shared action lost its plugin directory or pinned Node version") - } - if match[1] == "test" { - checks++ - } else { - publications++ - if step.With["target-branch"] != "headlamp-plugin" || step.With["metadata-template"] != "artifacthub-pkg.yml" { - t.Fatal("publication branch or first-release metadata is incorrect") - } - } - } - } - if (name == "pr" && (checks != 1 || publications != 0)) || (name == "release" && (checks != 0 || publications != 1)) { - t.Fatalf("%s: checks=%d publications=%d", name, checks, publications) - } - } -} - -func TestHeadlampMetadataMatchesPackage(t *testing.T) { - root := filepath.Join(repository(t), "headlamp") - data, err := os.ReadFile(filepath.Join(root, "package.json")) - if err != nil { - t.Fatal(err) - } - var pkg struct{ Name string } - if err := json.Unmarshal(data, &pkg); err != nil { - t.Fatal(err) - } - data, err = os.ReadFile(filepath.Join(root, "artifacthub-pkg.yml")) - if err != nil { - t.Fatal(err) - } - var metadata struct { - Name string - Annotations map[string]string - } - if err := yaml.Unmarshal(data, &metadata); err != nil { - t.Fatal(err) - } - if metadata.Name != pkg.Name || metadata.Annotations["headlamp/plugin/version-compat"] == "" || metadata.Annotations["headlamp/plugin/distro-compat"] == "" { - t.Fatal("first release metadata does not describe the plugin") - } -} diff --git a/hack/tooling/image_test.go b/hack/tooling/image_test.go deleted file mode 100644 index a5dd7a3..0000000 --- a/hack/tooling/image_test.go +++ /dev/null @@ -1,305 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - "sigs.k8s.io/yaml" -) - -func TestKoImageTasks(t *testing.T) { - for _, mode := range []string{"build", "push"} { - t.Run(mode, func(t *testing.T) { - dir, env := fixture(t) - archive := filepath.Join(dir, "output with spaces", "controller.tar") - successful(t, dir, env, "task", "image:"+mode, "IMG=localhost:5000/team/controller:dev", "IMAGE_ARCHIVE="+archive, "IMAGE_PLATFORM=linux/amd64") - log := logContents(t, dir) - for _, want := range []string{"ko\tbuild\t./cmd\t--bare\t--tags=dev", "repository=localhost:5000/team/controller"} { - if !strings.Contains(log, want) { - t.Fatalf("missing %q: %s", want, log) - } - } - if mode == "build" { - for _, want := range []string{"--push=false", "--platform=linux/amd64", "--tarball=" + archive, "--tag-only"} { - if !strings.Contains(log, want) { - t.Fatalf("local build lost %q: %s", want, log) - } - } - if _, err := os.Stat(archive); err != nil { - t.Fatal(err) - } - } else if !strings.Contains(log, "--push=true\t--platform=linux/amd64,linux/arm64") || strings.Contains(log, "--tarball") { - t.Fatalf("publish did not use both platforms: %s", log) - } - if strings.Contains(log, "docker") || strings.Contains(log, "--local") { - t.Fatalf("build unexpectedly uses a container daemon: %s", log) - } - }) - } -} - -func TestDefaultImageReference(t *testing.T) { - dir, env := fixture(t) - successful(t, dir, env, "task", "image:build", "VERSION=1.2.3") - log := logContents(t, dir) - if !strings.Contains(log, "repository=ghcr.io/projectcapsule/change-windows") || !strings.Contains(log, "--tags=1.2.3") { - t.Fatalf("wrong default image reference: %s", log) - } -} - -func TestImageRejectsInvalidInputs(t *testing.T) { - for _, image := range []string{"repo/image", "repo/image@sha256:abc", "repo/image:dev; touch unexpected", "repo/$(touch unexpected):dev", "repo/image:dev,latest"} { - t.Run(image, func(t *testing.T) { - dir, env := fixture(t) - if out, err := command(t, dir, env, "task", "image:build", "IMG="+image); err == nil || !strings.Contains(out, "IMG") { - t.Fatalf("invalid image accepted: %v %s", err, out) - } - if strings.Contains(logContents(t, dir), "ko\t") { - t.Fatal("ko ran for invalid input") - } - if _, err := os.Stat(filepath.Join(dir, "unexpected")); !os.IsNotExist(err) { - t.Fatal("input executed as shell code") - } - }) - } -} - -func TestImageFailureCannotReuseOldArtifact(t *testing.T) { - dir, env := fixture(t) - write(t, filepath.Join(dir, "dist/controller.tar"), "old archive") - if _, err := command(t, dir, append(env, "FAIL_TOOL=ko"), "task", "image:build", "IMG=example.com/controller:dev"); err == nil { - t.Fatal("ko failure was ignored") - } - for _, name := range []string{"dist/controller.tar"} { - if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) { - t.Fatalf("stale output %s remains: %v", name, err) - } - } -} - -func TestLocalImagePlatform(t *testing.T) { - dir, env := fixture(t) - successful(t, dir, env, "task", "image:build", "IMG=example.com/controller:dev") - if !strings.Contains(logContents(t, dir), "--platform=linux/arm64") { - t.Fatal("local image did not default to the host's architecture on Linux") - } - for _, platform := range []string{"darwin/arm64", "linux/amd64,linux/arm64"} { - if _, err := command(t, dir, env, "task", "image:build", "IMG=example.com/controller:dev", "IMAGE_PLATFORM="+platform); err == nil { - t.Fatalf("invalid local image platform accepted: %s", platform) - } - } -} - -func TestHelmReleaseTasks(t *testing.T) { - dir, env := fixture(t) - digest := "sha256:" + strings.Repeat("b", 64) - env = append(env, "HELM_DIGEST="+digest, "CHART_REGISTRY=oci://localhost:5000/charts", "GENERATOR_STDOUT=Generated manifests") - out, logs, err := stdoutCommand(t, dir, env, "task", "helm:push", "VERSION=v1.2.3-rc.1") - if err != nil || out != "localhost:5000/charts/change-windows@"+digest+"\n" { - t.Fatalf("incorrect published chart reference: %q %v\n%s", out, err, logs) - } - log := logContents(t, dir) - for _, want := range []string{ - "helm\tpackage\tcharts/change-windows\t--version\t1.2.3-rc.1\t--app-version\t1.2.3-rc.1", - "helm\tpush\tdist/charts/change-windows-1.2.3-rc.1.tgz\toci://localhost:5000/charts", - } { - if !strings.Contains(log, want) { - t.Fatalf("release lost %q: %s", want, log) - } - } - for _, file := range []string{"dist/chart-refs.txt", "dist/charts/push.log"} { - if _, err := os.Stat(filepath.Join(dir, file)); !os.IsNotExist(err) { - t.Fatalf("unexpected reference/log artifact %s: %v", file, err) - } - } - for _, failure := range []string{"FAIL_TOOL=helm", "HELM_DIGEST=invalid", "HELM_DIGEST=" + digest + "\nDigest: " + digest} { - out, logs, err := stdoutCommand(t, dir, append(env, failure), "task", "helm:push", "VERSION=1.2.3") - if err == nil || out != "" { - t.Fatalf("failed publishing returned a reference for %s: %q %v\n%s", failure, out, err, logs) - } - } -} - -func TestReleaseDigestOutputsAndSigning(t *testing.T) { - for _, tc := range []struct{ workflow, job, step, image string }{ - {"docker-publish.yml", "publish-images", "image", "localhost:5000/team/controller"}, - {"helm-publish.yml", "publish-helm-oci", "chart", "localhost:5000/charts/change-windows"}, - } { - t.Run(tc.step, func(t *testing.T) { - dir, env := fixture(t) - digest := "sha256:" + strings.Repeat("0", 64) - output := filepath.Join(dir, "github-output") - env = append(env, "GITHUB_OUTPUT="+output, "VERSION=1.2.3", "IMG=localhost:5000/team/controller:1.2.3", - "CHART_REGISTRY=oci://localhost:5000/charts", "HELM_DIGEST="+digest, "GENERATOR_STDOUT=Generated manifests") - successful(t, dir, env, "bash", "-euo", "pipefail", "-c", workflowStep(t, tc.workflow, tc.job, tc.step)) - data, err := os.ReadFile(output) - if err != nil || string(data) != "image="+tc.image+"\ndigest="+digest+"\n" { - t.Fatalf("wrong provenance output: %s %v", data, err) - } - ref := tc.image + "@" + digest - successful(t, dir, append(env, "IMAGE_REF="+ref), "task", "image:sign") - if !strings.Contains(logContents(t, dir), "cosign\tsign\t--yes\t"+ref) { - t.Fatal("signing did not use the published index/chart digest") - } - }) - } -} - -func TestKoPublishedIndexReference(t *testing.T) { - dir, env := fixture(t) - env = append(env, "GENERATOR_STDOUT=Generated objects") - out, logs, err := stdoutCommand(t, dir, env, "task", "image:push", "IMG=localhost:5000/team/controller:dev") - want := "localhost:5000/team/controller@sha256:" + strings.Repeat("0", 64) + "\n" - if err != nil || out != want { - t.Fatalf("expected one tag-free index reference, got %q: %v\n%s", out, err, logs) - } - if !strings.Contains(logs, "ko build progress") || !strings.Contains(logs, "Generated objects") { - t.Fatalf("build diagnostics were lost: %s", logs) - } - if strings.Contains(logContents(t, dir), "--image-refs") { - t.Fatal("publishing still uses the multi-reference recorder") - } - if _, err := os.Stat(filepath.Join(dir, "dist/image-refs.txt")); !os.IsNotExist(err) { - t.Fatalf("unexpected reference artifact: %v", err) - } -} - -func TestInvalidPublishedReferences(t *testing.T) { - valid := "example.com/controller@sha256:" + strings.Repeat("a", 64) - for _, invalid := range []string{"", "image:latest", valid + "\nmalicious=value", valid + "\n" + valid, "repo@sha256:short"} { - t.Run(invalid, func(t *testing.T) { - dir, env := fixture(t) - env = append(env, "KO_TEST_REFERENCE="+invalid, "IMAGE_REF="+invalid) - if _, err := command(t, dir, env, "task", "image:sign"); err == nil { - t.Fatal("signer accepted invalid reference") - } - out, _, err := stdoutCommand(t, dir, env, "task", "image:push", "IMG=example.com/controller:dev") - if err == nil || out != "" { - t.Fatalf("publisher returned invalid reference: %q %v", out, err) - } - if strings.Contains(logContents(t, dir), "cosign\t") { - t.Fatal("signer ran for invalid input") - } - }) - } - for _, failure := range []string{"FAIL_TOOL=ko", "KO_TEST_REFERENCE=wrong/repository@sha256:" + strings.Repeat("a", 64)} { - dir, env := fixture(t) - out, _, err := stdoutCommand(t, dir, append(env, failure), "task", "image:push", "IMG=example.com/controller:dev") - if err == nil || out != "" { - t.Fatalf("publisher returned reference after %s: %q %v", failure, out, err) - } - } -} - -func stdoutCommand(t *testing.T, dir string, environment []string, name string, args ...string) (string, string, error) { - t.Helper() - cmd := exec.Command(name, args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), environment...) - var diagnostics bytes.Buffer - cmd.Stderr = &diagnostics - out, err := cmd.Output() - return string(out), diagnostics.String(), err -} - -func TestReleaseVersionAndSnapshot(t *testing.T) { - dir, env := fixture(t) - output := filepath.Join(dir, "github-output") - successful(t, dir, append(env, "GITHUB_REF_NAME=v1.2.3-rc.1", "GITHUB_OUTPUT="+output), "bash", "-euo", "pipefail", "-c", workflowStep(t, "docker-publish.yml", "publish-images", "version")) - data, err := os.ReadFile(output) - if err != nil || string(data) != "version=1.2.3-rc.1\n" { - t.Fatalf("wrong release version: %s %v", data, err) - } - if _, err := command(t, dir, append(env, "GITHUB_REF_NAME=v1.2.3+build", "GITHUB_OUTPUT="+output), "bash", "-euo", "pipefail", "-c", workflowStep(t, "docker-publish.yml", "publish-images", "version")); err == nil { - t.Fatal("unsupported image tag accepted") - } - successful(t, dir, env, "task", "release:snapshot") - if !strings.Contains(logContents(t, dir), "goreleaser\trelease\t--snapshot\t--clean\t--skip=publish,sign") { - t.Fatal("snapshot could publish or sign artifacts") - } -} - -// Exercise GitHub metadata at its workflow consumer; build and publish remain Task targets. -func workflowStep(t *testing.T, file, job, id string) string { - t.Helper() - data, err := os.ReadFile(filepath.Join(repository(t), ".github/workflows", file)) - if err != nil { - t.Fatal(err) - } - var workflow struct { - Jobs map[string]struct{ Steps []struct{ ID, Run string } } - } - if err := yaml.Unmarshal(data, &workflow); err != nil { - t.Fatal(err) - } - for _, step := range workflow.Jobs[job].Steps { - if step.ID == id { - return step.Run - } - } - t.Fatalf("missing step %s/%s in %s", job, id, file) - return "" -} - -func TestEnterpriseRejectsInvalidControls(t *testing.T) { - for _, argument := range []string{"DRY_RUN=tru", "CHANNEL=unknown", "ENTERPRISE_VERSION="} { - t.Run(argument, func(t *testing.T) { - dir, env := fixture(t) - out, err := command(t, dir, env, "task", "enterprise:release", "ENTERPRISE_VERSION=1.2.3", argument) - if err == nil { - t.Fatalf("invalid control accepted: %s", out) - } - if log := logContents(t, dir); strings.Contains(log, "helm\t") || strings.Contains(log, "ko\t") { - t.Fatalf("invalid control reached release tools: %s", log) - } - }) - } -} - -func TestEnterpriseStopsBeforePublishingOnPackageFailure(t *testing.T) { - dir, env := fixture(t) - if out, err := command(t, dir, append(env, "FAIL_TOOL=helm"), "task", "enterprise:release", "ENTERPRISE_VERSION=1.2.3"); err == nil { - t.Fatalf("failed package accepted: %s", out) - } - log := logContents(t, dir) - if !strings.Contains(log, "helm\tpackage") || strings.Contains(log, "ko\t") || strings.Contains(log, "helm\tpush") { - t.Fatalf("publishing ran after package failure: %s", log) - } -} - -func TestChartVersionValidationUsesHelm(t *testing.T) { - helm, err := exec.LookPath("helm") - if err != nil { - t.Fatal(err) - } - for _, version := range []string{"v1.2.3-rc.1", "invalid", "1.2.3; touch unexpected"} { - t.Run(version, func(t *testing.T) { - dir, env := fixture(t) - copyFile(t, repository(t), dir, "charts/change-windows/Chart.yaml") - write(t, filepath.Join(dir, "tools/helm"), "#!/usr/bin/env bash\nexec \"$REAL_HELM\" \"$@\"\n") - out, err := command(t, dir, append(env, "REAL_HELM="+helm), "task", "helm:package", "VERSION="+version) - if version == "v1.2.3-rc.1" { - if err != nil { - t.Fatalf("valid version rejected: %v %s", err, out) - } - if _, err := os.Stat(filepath.Join(dir, "dist/charts/change-windows-1.2.3-rc.1.tgz")); err != nil { - t.Fatal(err) - } - } else if err == nil || !strings.Contains(strings.ToLower(out), "invalid semantic version") { - t.Fatalf("expected Helm's version error: %v %s", err, out) - } - if _, err := os.Stat(filepath.Join(dir, "unexpected")); !os.IsNotExist(err) { - t.Fatal("version executed as shell code") - } - }) - } -} diff --git a/hack/tooling/playground_test.go b/hack/tooling/playground_test.go deleted file mode 100644 index 33c5567..0000000 --- a/hack/tooling/playground_test.go +++ /dev/null @@ -1,262 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "os" - "os/exec" - "path/filepath" - "strings" - "syscall" - "testing" - "time" - - admissionv1 "k8s.io/api/admissionregistration/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - "sigs.k8s.io/yaml" -) - -func playgroundFixture(t *testing.T) (string, []string) { - t.Helper() - dir, env := deploymentFixture(t) - for _, file := range []string{"operator-values.yaml", "headlamp-values.yaml", "resources.yaml", "window.yaml"} { - copyFile(t, repository(t), dir, "hack/playground/"+file) - } - copyFile(t, repository(t), dir, "examples/executor-rbac.yaml") - write(t, filepath.Join(dir, "headlamp/dist/main.js"), "playground plugin\n") - write(t, filepath.Join(dir, "headlamp/dist/package.json"), `{ "name": "capsule-change-windows" }`) - for name, script := range map[string]string{ - "kind": ` -if [[ "$1 $2" == 'get clusters' ]]; then - if [[ "${EXISTING_CLUSTER:-}" == true ]]; then echo change-windows-playground; fi -elif [[ "$1 $2" == 'create cluster' ]]; then - [[ "$KUBECONFIG" != "$ORIGINAL_CONFIG" ]] - printf '%s' "$KUBECONFIG" > "$PRIVATE_CONFIG_RECORD" - [[ "${FAIL_STAGE:-}" != cluster ]] -elif [[ "$1 $2" == 'get kubeconfig' ]]; then - echo 'private fixture kubeconfig' -fi -`, - "curl": ` -if [[ "${FAIL_STAGE:-}" == forward ]]; then sleep 0.2; exit 37; fi -while (( $# )); do - if [[ "$1" == -o ]]; then cp headlamp/dist/main.js "$2"; break; fi - shift -done -`, - "date": ` -if [[ "$*" == '+%s' && "${WAIT_PLAYGROUND:-}" != true ]]; then - if [[ -f "$CLOCK_RECORD" ]]; then echo 9999; else touch "$CLOCK_RECORD"; echo 1; fi -else - /bin/date "$@" -fi -`, - } { - executable := filepath.Join(dir, "tools", name) - write(t, executable, "#!/usr/bin/env bash\nset -euo pipefail\nprintf '%s' '${NAME}' >> \"$TOOL_LOG\"\nprintf '\\t%s' \"$@\" >> \"$TOOL_LOG\"\nprintf '\\n' >> \"$TOOL_LOG\"\n"+script) - data, err := os.ReadFile(executable) - if err != nil { - t.Fatal(err) - } - write(t, executable, strings.ReplaceAll(string(data), "${NAME}", name)) - if err := os.Chmod(executable, 0o755); err != nil { - t.Fatal(err) - } - } - // Keep existing dev:deploy responses, adding the long-lived port-forward. - kubectl := filepath.Join(dir, "tools/kubectl") - data, err := os.ReadFile(kubectl) - if err != nil { - t.Fatal(err) - } - write(t, kubectl, strings.Replace(string(data), "set -euo pipefail", `set -euo pipefail -if [[ "${FAIL_STAGE:-}" == certificates && "$*" == *'get crd'* ]]; then exit 37; fi`, 1)+` -if [[ "$*" == *port-forward* ]]; then - [[ "${FAIL_STAGE:-}" != forward ]] || exit 37 - trap 'exit 0' TERM INT - while true; do sleep 0.1; done -fi -`) - original := filepath.Join(dir, "developer kubeconfig") - write(t, original, "untouched developer context\n") - return dir, append(env, "KUBECONFIG="+original, "ORIGINAL_CONFIG="+original, - "PRIVATE_CONFIG_RECORD="+filepath.Join(dir, "private-config"), "CLOCK_RECORD="+filepath.Join(dir, "clock")) -} - -func assertPlaygroundCleaned(t *testing.T, dir string) { - t.Helper() - log := logContents(t, dir) - if !strings.Contains(log, "kind\tdelete\tcluster\t--name\tchange-windows-playground") { - t.Fatalf("missing cluster cleanup:\n%s", log) - } - config, err := os.ReadFile(filepath.Join(dir, "private-config")) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Dir(string(config))); !os.IsNotExist(err) { - t.Fatalf("temporary credentials or plugin assets remain: %v", err) - } - original, err := os.ReadFile(filepath.Join(dir, "developer kubeconfig")) - if err != nil || string(original) != "untouched developer context\n" { - t.Fatalf("developer context changed: %v %s", err, original) - } -} - -func TestPlaygroundLifecycle(t *testing.T) { - for _, stage := range []string{"expiry", "cluster", "certificates", "forward"} { - t.Run(stage, func(t *testing.T) { - dir, env := playgroundFixture(t) - out, err := command(t, dir, append(env, "FAIL_STAGE="+stage), "task", "playground", "PLAYGROUND_MINUTES=1") - if stage == "expiry" { - if err != nil { - t.Fatalf("playground failed: %v\n%s", err, out) - } - if !strings.Contains(out, "Playground ready:") { - t.Fatal("readiness missing") - } - log := logContents(t, dir) - for _, want := range []string{"npm\tci", "npm\trun\tbuild", "--push=false", "--values\thack/playground/headlamp-values.yaml", "--version\t0.45.0", "--address=127.0.0.1", "changewindow/playground\t--for=condition=Ready"} { - if !strings.Contains(log, want) { - t.Errorf("missing %q in %s", want, log) - } - } - if strings.Contains(log, "--push=true") || strings.Contains(log, "use-context") { - t.Fatal("playground published or changed context") - } - } else if err == nil { - t.Fatalf("%s failure accepted: %s", stage, out) - } - assertPlaygroundCleaned(t, dir) - }) - } -} - -func TestPlaygroundRejectsUnsafeSetup(t *testing.T) { - for _, tc := range []struct{ name, arg, env string }{ - {"existing cluster", "", "EXISTING_CLUSTER=true"}, - {"zero lifetime", "PLAYGROUND_MINUTES=0", ""}, - {"unbounded lifetime", "PLAYGROUND_MINUTES=31", ""}, - {"bad lifetime", "PLAYGROUND_MINUTES=;echo bad", ""}, - {"bad port", "PLAYGROUND_PORT=70000", ""}, - {"bad name", "PLAYGROUND_CLUSTER=../../other", ""}, - } { - t.Run(tc.name, func(t *testing.T) { - dir, env := playgroundFixture(t) - args := []string{"playground"} - if tc.arg != "" { - args = append(args, tc.arg) - } - if tc.env != "" { - env = append(env, tc.env) - } - out, err := command(t, dir, env, "task", args...) - if err == nil { - t.Fatalf("unsafe setup accepted: %s", out) - } - log := logContents(t, dir) - if strings.Contains(log, "kind\tcreate") || strings.Contains(log, "kind\tdelete") || strings.Contains(log, "npm\t") { - t.Fatalf("unsafe setup modified resources: %s", log) - } - }) - } -} - -func TestPlaygroundSignalCleanup(t *testing.T) { - dir, env := playgroundFixture(t) - output, err := os.Create(filepath.Join(dir, "output.log")) - if err != nil { - t.Fatal(err) - } - t.Cleanup(func() { - if err := output.Close(); err != nil { - t.Error(err) - } - }) - cmd := exec.Command("task", "playground") - cmd.Dir = dir - cmd.Env = append(os.Environ(), append(env, "WAIT_PLAYGROUND=true")...) - cmd.Stdout = output - cmd.Stderr = output - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }) - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - deadline := time.Now().Add(15 * time.Second) - for { - data, err := os.ReadFile(output.Name()) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(data), "Playground ready:") { - break - } - if time.Now().After(deadline) { - t.Fatalf("not ready: %s", data) - } - select { - case err := <-done: - t.Fatalf("exited early: %v\n%s", err, data) - default: - } - time.Sleep(20 * time.Millisecond) - } - if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { - t.Fatal(err) - } - select { - case <-done: - case <-time.After(10 * time.Second): - t.Fatal("cancellation did not exit") - } - assertPlaygroundCleaned(t, dir) -} - -func TestPlaygroundAdmissionScope(t *testing.T) { - root := repository(t) - output := successful(t, root, nil, "helm", "template", "change-windows", "charts/change-windows", "--namespace", "schedule-action-controller-system", "--values", "hack/playground/operator-values.yaml") - found := false - for _, document := range strings.Split(output, "\n---\n") { - var config admissionv1.ValidatingWebhookConfiguration - if err := yaml.Unmarshal([]byte(document), &config); err != nil { - t.Fatal(err) - } - if config.Kind != "ValidatingWebhookConfiguration" { - continue - } - for _, hook := range config.Webhooks { - if hook.ClientConfig.Service == nil || hook.ClientConfig.Service.Path == nil || *hook.ClientConfig.Service.Path != "/validate-resources" { - continue - } - found = true - selector, err := metav1.LabelSelectorAsSelector(hook.NamespaceSelector) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - name string - tenant bool - want bool - }{ - {"development", false, true}, {"development", true, true}, {"other", false, false}, {"other", true, false}, {"kube-system", false, false}, {"schedule-action-controller-system", false, false}, - } { - namespaceLabels := labels.Set{"kubernetes.io/metadata.name": tc.name} - if tc.tenant { - namespaceLabels["capsule.clastix.io/tenant"] = "example" - } - if got := selector.Matches(namespaceLabels); got != tc.want { - t.Errorf("namespace %s tenant=%t: matched %t, want %t", tc.name, tc.tenant, got, tc.want) - } - } - } - } - if !found { - t.Fatal("resource admission webhook not rendered") - } -} diff --git a/hack/tooling/release_workflows_test.go b/hack/tooling/release_workflows_test.go deleted file mode 100644 index 7f0eee6..0000000 --- a/hack/tooling/release_workflows_test.go +++ /dev/null @@ -1,221 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "testing" - - "sigs.k8s.io/yaml" -) - -func TestChartPublicationAuthenticatesHelmAndCosign(t *testing.T) { - data, err := os.ReadFile(filepath.Join(repository(t), ".github/workflows/helm-publish.yml")) - if err != nil { - t.Fatal(err) - } - var workflow struct { - Jobs map[string]struct{ Steps []struct{ Name, Run string } } - } - if err := yaml.Unmarshal(data, &workflow); err != nil { - t.Fatal(err) - } - var login string - for _, step := range workflow.Jobs["publish-helm-oci"].Steps { - if step.Name == "Authenticate registry" { - login = step.Run - } - if step.Name == "Package and publish chart" && login == "" { - t.Fatal("chart publication precedes registry authentication") - } - } - if login == "" { - t.Fatal("missing registry authentication") - } - for _, failure := range []string{"", "helm", "ko"} { - t.Run("failure="+failure, func(t *testing.T) { - dir, env := fixture(t) - env = append(env, "REGISTRY_USERNAME=release-bot", "REGISTRY_PASSWORD=fixture-password", "FAIL_TOOL="+failure) - out, err := command(t, dir, env, "bash", "-euo", "pipefail", "-c", login) - if (err != nil) != (failure != "") { - t.Fatalf("wrong authentication result: %v\n%s", err, out) - } - log := logContents(t, dir) - if failure == "" { - for _, want := range []string{ - "helm\tregistry\tlogin\tghcr.io\t--username\trelease-bot\t--password-stdin", - "ko\tlogin\tghcr.io\t--username\trelease-bot\t--password-stdin", - } { - if !strings.Contains(log, want) { - t.Errorf("missing authentication %q: %s", want, log) - } - } - } - if strings.Contains(log+out, "fixture-password") { - t.Fatal("registry password leaked into arguments or output") - } - }) - } -} - -func TestGoReleaserRegistersSigningBundles(t *testing.T) { - goreleaser, err := exec.LookPath("goreleaser") - if err != nil { - t.Fatal(err) - } - data, err := os.ReadFile(filepath.Join(repository(t), ".goreleaser.yml")) - if err != nil { - t.Fatal(err) - } - var release struct{ Signs []map[string]any } - if err := yaml.Unmarshal(data, &release); err != nil { - t.Fatal(err) - } - dir, env := fixture(t) - // Exercise the real GoReleaser with a tiny binary and the production signing - // configuration. Only the remote signer is replaced; no token or registry is used. - if err := os.Remove(filepath.Join(dir, "tools/go")); err != nil { - t.Fatal(err) - } - write(t, filepath.Join(dir, "go.mod"), "module example.invalid/signing-test\n\ngo 1.25.0\n") - write(t, filepath.Join(dir, "main.go"), "package main\nfunc main() {}\n") - config := map[string]any{ - "version": 2, - "project_name": "signing-test", - "builds": []map[string]any{{"main": ".", "goos": []string{"linux"}, "goarch": []string{"amd64"}, "env": []string{"CGO_ENABLED=0"}}}, - "changelog": map[string]any{"disable": true}, - "signs": release.Signs, - } - data, err = yaml.Marshal(config) - if err != nil { - t.Fatal(err) - } - write(t, filepath.Join(dir, ".goreleaser.yml"), string(data)) - write(t, filepath.Join(dir, "tools/cosign"), `#!/usr/bin/env bash -set -euo pipefail -[[ "$1" == sign-blob ]] -shift -bundle='' -artifact='' -for arg in "$@"; do - case "$arg" in - --bundle=*) bundle="${arg#*=}" ;; - --yes) ;; - --output-signature=*) ;; # Cosign 3 ignores this for bundle signing. - *) artifact="$arg" ;; - esac -done -[[ -n "$bundle" && -f "$artifact" ]] || { echo 'missing bundle output or artifact' >&2; exit 1; } -printf '{"testBundle":true}\n' > "$bundle" -`) - successful(t, dir, nil, "git", "remote", "add", "origin", "https://github.com/example/signing-test.git") - successful(t, dir, nil, "git", "add", "go.mod", "main.go", ".goreleaser.yml") - successful(t, dir, nil, "git", "-c", "commit.gpgsign=false", "commit", "-qm", "test: signing fixture") - successful(t, dir, nil, "git", "-c", "tag.gpgsign=false", "tag", "v1.0.0") - env = append(env, "GITHUB_TOKEN=", "GITLAB_TOKEN=", "GITEA_TOKEN=", "GOTOOLCHAIN=local") - successful(t, dir, env, goreleaser, "release", "--skip=publish,validate", "--clean", "--parallelism=1") - data, err = os.ReadFile(filepath.Join(dir, "dist/artifacts.json")) - if err != nil { - t.Fatal(err) - } - var artifacts []struct{ Name, Path, Type string } - if err := json.Unmarshal(data, &artifacts); err != nil { - t.Fatal(err) - } - bundles := 0 - for _, artifact := range artifacts { - if artifact.Type != "Signature" { - continue - } - bundles++ - if !strings.HasSuffix(artifact.Name, ".sigstore.json") { - t.Errorf("release would upload a legacy signature: %s", artifact.Name) - } - if data, err := os.ReadFile(filepath.Join(dir, artifact.Path)); err != nil || !json.Valid(data) { - t.Errorf("registered signature has no bundle file: %s: %v", artifact.Path, err) - } - } - if bundles != 2 { - t.Fatalf("expected archive and checksum signing bundles, got %d: %s", bundles, data) - } -} - -func TestProvenanceGeneratorConfiguration(t *testing.T) { - for _, tc := range []struct{ file, job string }{ - {"docker-publish.yml", "provenance"}, - {"helm-publish.yml", "helm-provenance"}, - } { - data, err := os.ReadFile(filepath.Join(repository(t), ".github/workflows", tc.file)) - if err != nil { - t.Fatal(err) - } - var workflow struct { - Jobs map[string]struct { - Uses string - With map[string]string - } - } - if err := yaml.Unmarshal(data, &workflow); err != nil { - t.Fatal(err) - } - job := workflow.Jobs[tc.job] - ref := strings.TrimPrefix(job.Uses, "slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@") - // The generator downloads its builder by release tag and binds that tag - // into the signing identity; SHA references cannot be substituted here. - if !regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`).MatchString(ref) { - t.Errorf("%s: SLSA needs a full release tag, got %q", tc.file, job.Uses) - } - if job.With["private-repository"] != "${{ vars.SLSA_PUBLIC_PROVENANCE == 'true' }}" { - t.Errorf("%s: private repository metadata publication must require an explicit opt-in", tc.file) - } - if job.With["continue-on-error"] == "true" { - t.Errorf("%s: failed provenance must fail the release", tc.file) - } - } -} - -func TestScorecardPrivateRepositoryAccess(t *testing.T) { - data, err := os.ReadFile(filepath.Join(repository(t), ".github/workflows/scorecard.yml")) - if err != nil { - t.Fatal(err) - } - var workflow struct { - Jobs map[string]struct { - Permissions map[string]string - Steps []struct { - Uses string - With map[string]string - } - } - } - if err := yaml.Unmarshal(data, &workflow); err != nil { - t.Fatal(err) - } - job := workflow.Jobs["analysis"] - for _, permission := range []string{"contents", "issues", "pull-requests", "checks"} { - if job.Permissions[permission] != "read" { - t.Errorf("private repository analysis needs %s: read", permission) - } - } - for _, step := range job.Steps { - if !strings.HasPrefix(step.Uses, "ossf/scorecard-action@") { - continue - } - if step.With["repo_token"] != "${{ secrets.SCORECARD_READ_TOKEN || github.token }}" { - t.Error("Scorecard must work without a separately provisioned PAT") - } - if step.With["publish_results"] != "${{ !github.event.repository.private }}" { - t.Error("private Scorecard results must not be published to the public service") - } - return - } - t.Fatal("missing Scorecard analysis") -} diff --git a/hack/tooling/taskfile_test.go b/hack/tooling/taskfile_test.go deleted file mode 100644 index bf169df..0000000 --- a/hack/tooling/taskfile_test.go +++ /dev/null @@ -1,269 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "regexp" - "strings" - "syscall" - "testing" - "time" - - "sigs.k8s.io/yaml" -) - -func TestTaskShell(t *testing.T) { - data, err := os.ReadFile(filepath.Join(repository(t), "Taskfile.yml")) - if err != nil { - t.Fatal(err) - } - var taskfile struct { - Tasks map[string]struct { - Cmds []any - Preconditions []struct{ Sh string } - } - } - if err := yaml.Unmarshal(data, &taskfile); err != nil { - t.Fatal(err) - } - template := regexp.MustCompile("{{.*?}}") - for name, task := range taskfile.Tasks { - commands := append([]any(nil), task.Cmds...) - for _, precondition := range task.Preconditions { - commands = append(commands, precondition.Sh) - } - for index, command := range commands { - source, ok := command.(string) - if !ok { - mapping, ok := command.(map[string]any) - if !ok { - t.Fatalf("unsupported command in %s: %T", name, command) - } - if _, ok := mapping["task"]; ok { - continue // Task calls have no shell source. - } - source, ok = mapping["cmd"].(string) - if !ok { - t.Fatalf("missing shell command in %s: %v", name, mapping) - } - } - t.Run(fmt.Sprintf("%s/%d", name, index), func(t *testing.T) { - // Lint the actual Bash body, not just its enclosing here-document. - if strings.HasPrefix(source, "bash -euo pipefail <<'BASH'\n") { - source = strings.TrimPrefix(source, "bash -euo pipefail <<'BASH'\n") - source = strings.TrimSuffix(strings.TrimSpace(source), "\nBASH") - } - // Task substitutes these before execution; forwarding/quoting is - // exercised by the hook and command-behavior tests. - source = template.ReplaceAllString(source, "task_argument") - cmd := exec.Command("shellcheck", "--shell=bash", "-") - cmd.Stdin = strings.NewReader("set -euo pipefail\n" + source + "\n") - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("inline shell failed ShellCheck: %v\n%s", err, out) - } - }) - } - } -} - -func TestE2EClusterCleanup(t *testing.T) { - for _, existing := range []bool{false, true} { - t.Run(fmt.Sprintf("existing=%t", existing), func(t *testing.T) { - dir, env := fixture(t) - kind := filepath.Join(dir, "tools/kind") - write(t, kind, `#!/usr/bin/env bash -set -euo pipefail -printf 'kind' >> "$TOOL_LOG" -printf '\t%s' "$@" >> "$TOOL_LOG" -printf '\tkubeconfig=%s\n' "$KUBECONFIG" >> "$TOOL_LOG" -if [[ "$1 $2" == "get clusters" ]]; then - if [[ "$EXISTING_CLUSTER" == true ]]; then printf 'isolated\n'; fi -elif [[ "$1 $2" == "create cluster" ]]; then - [[ "$KUBECONFIG" != "$ORIGINAL_CONFIG" && -f "$KUBECONFIG" ]] - printf '%s' "$KUBECONFIG" > "$PRIVATE_CONFIG_RECORD" - exit 37 -fi -`) - if err := os.Chmod(kind, 0o755); err != nil { - t.Fatal(err) - } - original := filepath.Join(dir, "developer kubeconfig") - write(t, original, "developer context\n") - privateRecord := filepath.Join(dir, "private-config") - env = append(env, "KUBECONFIG="+original, "ORIGINAL_CONFIG="+original, - "PRIVATE_CONFIG_RECORD="+privateRecord, fmt.Sprintf("EXISTING_CLUSTER=%t", existing)) - out, err := command(t, dir, env, "task", "e2e", "KIND_CLUSTER=isolated") - if err == nil { - t.Fatalf("expected setup failure, got success: %s", out) - } - log := logContents(t, dir) - if existing { - if !strings.Contains(out, "Refusing to reuse or delete existing cluster isolated") || - strings.Contains(log, "kind\tcreate") || strings.Contains(log, "kind\tdelete") { - t.Fatalf("existing cluster was not preserved: %s\n%s", out, log) - } - } else { - for _, call := range []string{"kind\tcreate\tcluster", "kind\texport\tlogs\t--name\tisolated", "kind\tdelete\tcluster\t--name\tisolated"} { - if !strings.Contains(log, call) { - t.Errorf("cleanup missing %q: %s", call, log) - } - } - config, err := os.ReadFile(privateRecord) - if err != nil { - t.Fatal(err) - } - if _, err := os.Stat(string(config)); !os.IsNotExist(err) { - t.Fatalf("temporary credentials remained after failure: %v", err) - } - } - data, err := os.ReadFile(original) - if err != nil || string(data) != "developer context\n" { - t.Fatalf("developer kubeconfig changed: %s %v", data, err) - } - }) - } -} - -func TestHooksUseTaskTargets(t *testing.T) { - root := repository(t) - data, err := os.ReadFile(filepath.Join(root, "prek.toml")) - if err != nil { - t.Fatal(err) - } - entries := regexp.MustCompile("(?m)^entry = \"([^\"]+)\"$").FindAllStringSubmatch(string(data), -1) - if len(entries) == 0 { - t.Fatal("no hook entries") - } - out := successful(t, root, nil, "task", "--list-all", "--json") - var list struct { - Tasks []struct{ Name string } - } - if err := json.Unmarshal([]byte(out), &list); err != nil { - t.Fatal(err) - } - targets := make(map[string]bool) - for _, task := range list.Tasks { - targets[task.Name] = true - } - for _, entry := range entries { - if !strings.HasPrefix(entry[1], "devbox run -- task ") { - t.Fatalf("hook bypasses Task or the locked environment: %s", entry[1]) - } - for _, target := range strings.Fields(strings.TrimPrefix(entry[1], "devbox run -- task ")) { - if target == "--" { - break - } - if !targets[target] { - t.Fatalf("hook calls an unknown task %q", target) - } - } - } - scripts, err := filepath.Glob(filepath.Join(root, "hack/*.sh")) - if err != nil || len(scripts) != 0 { - t.Fatalf("task implementations must live in Taskfile.yml: %v %v", scripts, err) - } -} - -func TestManifestsTargetHelm(t *testing.T) { - dir, env := fixture(t) - successful(t, dir, env, "task", "manifests") - log := logContents(t, dir) - for _, want := range []string{ - "rbac:roleName=manager-role,fileName=manager-role.yaml", - "output:crd:dir=charts/change-windows/crds", - "output:rbac:dir=charts/change-windows/files", - } { - if !strings.Contains(log, want) { - t.Errorf("missing Helm output %q: %s", want, log) - } - } - if _, err := os.Stat(filepath.Join(dir, "config")); !os.IsNotExist(err) { - t.Fatalf("manifest generation must not recreate config/: %v", err) - } -} - -func TestE2EStopsWhenClusterDiscoveryFails(t *testing.T) { - dir, env := deploymentFixture(t) - out, err := command(t, dir, append(env, "FAIL_TOOL=kind"), "task", "e2e", "KIND_CLUSTER=isolated") - if err == nil { - t.Fatalf("failed discovery accepted: %s", out) - } - log := logContents(t, dir) - if !strings.Contains(log, "kind\tget\tclusters") || strings.Contains(log, "kind\tcreate") || strings.Contains(log, "kind\tdelete") || strings.Contains(log, "ko\t") { - t.Fatalf("failed cluster discovery did not stop setup: %s", log) - } -} - -func TestE2ESignalCleanup(t *testing.T) { - dir, env := fixture(t) - kind := filepath.Join(dir, "tools/kind") - write(t, kind, `#!/usr/bin/env bash -set -euo pipefail -printf 'kind\t%s\t%s\n' "$1" "$2" >> "$TOOL_LOG" -if [[ "$1 $2" == 'create cluster' ]]; then - printf '%s' "$KUBECONFIG" > "$PRIVATE_CONFIG_RECORD" - trap 'exit 143' TERM INT - while true; do sleep 0.1; done -fi -`) - if err := os.Chmod(kind, 0o755); err != nil { - t.Fatal(err) - } - record := filepath.Join(dir, "private-config") - output, err := os.Create(filepath.Join(dir, "output.log")) - if err != nil { - t.Fatal(err) - } - defer output.Close() - cmd := exec.Command("task", "e2e", "KIND_CLUSTER=isolated") - cmd.Dir = dir - cmd.Env = append(os.Environ(), append(env, "PRIVATE_CONFIG_RECORD="+record)...) - cmd.Stdout, cmd.Stderr = output, output - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }) - done := make(chan error, 1) - go func() { done <- cmd.Wait() }() - deadline := time.Now().Add(10 * time.Second) - var config []byte - for time.Now().Before(deadline) { - config, _ = os.ReadFile(record) - if len(config) > 0 { - break - } - time.Sleep(20 * time.Millisecond) - } - if len(config) == 0 { - t.Fatal("cluster creation did not start") - } - if err := syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM); err != nil { - t.Fatal(err) - } - select { - case err := <-done: - if err == nil { - t.Fatal("interrupted e2e reported success") - } - case <-time.After(10 * time.Second): - t.Fatal("interrupted e2e did not terminate") - } - log := logContents(t, dir) - for _, call := range []string{"kind\texport\tlogs", "kind\tdelete\tcluster"} { - if !strings.Contains(log, call) { - t.Errorf("missing cleanup %q: %s", call, log) - } - } - if _, err := os.Stat(string(config)); !os.IsNotExist(err) { - t.Fatalf("temporary credentials retained: %v", err) - } -} diff --git a/hack/tooling/tooling_test.go b/hack/tooling/tooling_test.go deleted file mode 100644 index 5854151..0000000 --- a/hack/tooling/tooling_test.go +++ /dev/null @@ -1,326 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" -) - -func repository(t *testing.T) string { - t.Helper() - root, err := filepath.Abs("../..") - if err != nil { - t.Fatal(err) - } - return root -} - -func write(t *testing.T, filename, data string) { - t.Helper() - if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filename, []byte(data), 0o600); err != nil { - t.Fatal(err) - } -} - -func copyFile(t *testing.T, root, destination, filename string) { - t.Helper() - data, err := os.ReadFile(filepath.Join(root, filename)) - if err != nil { - t.Fatal(err) - } - write(t, filepath.Join(destination, filename), string(data)) -} - -func command(t *testing.T, dir string, environment []string, name string, args ...string) (string, error) { - t.Helper() - cmd := exec.Command(name, args...) - cmd.Dir = dir - cmd.Env = append(os.Environ(), environment...) - out, err := cmd.CombinedOutput() - return string(out), err -} - -func successful(t *testing.T, dir string, environment []string, name string, args ...string) string { - t.Helper() - out, err := command(t, dir, environment, name, args...) - if err != nil { - t.Fatalf("%s %v failed: %v\n%s", name, args, err, out) - } - return out -} - -func fixture(t *testing.T) (string, []string) { - t.Helper() - dir, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - root := repository(t) - for _, name := range []string{"Taskfile.yml", "prek.toml", "hack/prek-builtins.toml", "hack/dev/readiness.yaml"} { - copyFile(t, root, dir, name) - } - successful(t, dir, nil, "git", "init", "-q") - successful(t, dir, nil, "git", "config", "user.name", "Tooling Test") - successful(t, dir, nil, "git", "config", "user.email", "tooling-test@example.invalid") - for _, name := range []string{"charts/change-windows/crds", "charts/change-windows/files", "assets with spaces"} { - if err := os.MkdirAll(filepath.Join(dir, name), 0o755); err != nil { - t.Fatal(err) - } - } - bin := filepath.Join(dir, "tools") - for _, name := range []string{"devbox", "npm", "go", "controller-gen", "setup-envtest", "ko", "helm", "kubectl", "cosign", "goreleaser"} { - filename := filepath.Join(bin, name) - write(t, filename, `#!/usr/bin/env bash -set -euo pipefail -name="${0##*/}" -printf '%s' "$name" >> "$TOOL_LOG" -printf '\t%s' "$@" >> "$TOOL_LOG" -printf '\tassets=%s\n' "${KUBEBUILDER_ASSETS:-}" >> "$TOOL_LOG" -if [[ "$name" == ko ]]; then printf 'repository=%s\n' "${KO_DOCKER_REPO:-}" >> "$TOOL_LOG"; fi -if [[ "${FAIL_TOOL:-}" == "$name" ]]; then exit 37; fi -if [[ "$name" == devbox ]]; then - [[ "$1" == run && "$2" == -- ]] || exit 1 - shift 2 - exec "$@" -fi -if [[ "$name" == go && "${4:-}" == crd-ref-docs ]]; then - for arg in "$@"; do - case "$arg" in - --output-path=*) printf '# API Reference\n#### Example\nA generated type.\n' > "${arg#*=}" ;; - esac - done -fi -if [[ "$name" == go && "${1:-}" == build && "${2:-}" == -o ]]; then - cat > "$3" <<'MANAGER' -#!/usr/bin/env bash -printf 'manager' >> "$TOOL_LOG" -printf '\t%s' "$@" >> "$TOOL_LOG" -printf '\n' >> "$TOOL_LOG" -if [[ "${WAIT_MANAGER:-}" == true ]]; then - echo "$$" > "$MANAGER_PID_FILE" - trap 'exit 0' TERM INT - while true; do sleep 0.1; done -fi -MANAGER - chmod +x "$3" -fi -if [[ "$name" == setup-envtest ]]; then printf '%s\n' "$ASSET_DIRECTORY"; fi -if [[ "$name" == go && "${1:-}" == env && "${2:-}" == GOHOSTARCH ]]; then printf 'arm64\n'; fi -if [[ "$name" == controller-gen && -n "${GENERATOR_STDOUT:-}" ]]; then printf '%s\n' "$GENERATOR_STDOUT"; fi -if [[ "$name" == helm && "${1:-}" == package ]]; then printf 'Packaged chart\n'; fi -if [[ "$name" == helm && "${1:-}" == push ]]; then printf 'Digest: %s\n' "${HELM_DIGEST:-invalid}"; fi -if [[ "$name" == ko && "${1:-}" == build ]]; then - tag=latest - for arg in "$@"; do - case "$arg" in - --tags=*) tag="${arg#*=}" ;; - # ko records the index AND child manifests for multi-platform builds. - --image-refs=*) - printf '%s@sha256:%064d\n' "$KO_DOCKER_REPO" 0 > "${arg#*=}" - if [[ "$*" == *linux/amd64,linux/arm64* ]]; then - printf '%s@sha256:%064d\n' "$KO_DOCKER_REPO" 1 "$KO_DOCKER_REPO" 2 >> "${arg#*=}" - fi ;; - --tarball=*) printf 'test image archive\n' > "${arg#*=}" ;; - esac - done - printf 'ko build progress\n' >&2 - if [[ "${KO_TEST_REFERENCE+x}" ]]; then - printf '%s\n' "$KO_TEST_REFERENCE" - else - printf '%s:%s@sha256:%064d\n' "$KO_DOCKER_REPO" "$tag" 0 - fi -fi -`) - if err := os.Chmod(filename, 0o755); err != nil { - t.Fatal(err) - } - } - write(t, filepath.Join(dir, "tools.log"), "") - return dir, []string{ - "PATH=" + bin + string(os.PathListSeparator) + os.Getenv("PATH"), - "TOOL_LOG=" + filepath.Join(dir, "tools.log"), - "ASSET_DIRECTORY=" + filepath.Join(dir, "assets with spaces"), - "KUBEBUILDER_ASSETS=", - } -} - -func logContents(t *testing.T, dir string) string { - t.Helper() - data, err := os.ReadFile(filepath.Join(dir, "tools.log")) - if err != nil { - t.Fatal(err) - } - return string(data) -} - -func TestTaskTestEnvironment(t *testing.T) { - for _, tc := range []struct { - name string - supplied bool - relative bool - }{ - {name: "download"}, - {name: "download-relative", relative: true}, - {name: "supplied", supplied: true}, - {name: "supplied-relative", supplied: true, relative: true}, - } { - t.Run(tc.name, func(t *testing.T) { - dir, env := fixture(t) - assets := filepath.Join(dir, "assets with spaces") - argument := assets - if tc.relative { - argument = filepath.Base(assets) - } - env = append(env, "ASSET_DIRECTORY="+argument) - if tc.supplied { - env = append(env, "KUBEBUILDER_ASSETS="+argument) - } - successful(t, dir, env, "task", "test", "GO_TEST_FLAGS=-race -count=1", "ENVTEST_K8S_VERSION=1.36.0") - log := logContents(t, dir) - if !strings.Contains(log, "go\ttest\t-race\t-count=1\t./...\t-coverprofile=coverage.out\tassets="+assets) { - t.Fatalf("test flags/assets were lost: %s", log) - } - if strings.Contains(log, "setup-envtest\t") == tc.supplied { - t.Fatalf("wrong envtest downloader behavior: %s", log) - } - if strings.Count(log, "controller-gen\t") != 2 { - t.Fatalf("generation did not precede tests: %s", log) - } - }) - } -} - -func TestTaskFailurePropagation(t *testing.T) { - for _, tool := range []string{"controller-gen", "setup-envtest", "go"} { - t.Run(tool, func(t *testing.T) { - dir, env := fixture(t) - _, err := command(t, dir, append(env, "FAIL_TOOL="+tool), "task", "test") - if err == nil { - t.Fatal("failed tool was accepted") - } - if tool != "go" && strings.Contains(logContents(t, dir), "go\ttest") { - t.Fatal("tests ran after a prerequisite failed") - } - }) - } -} - -func TestTaskUnitAlias(t *testing.T) { - dir, env := fixture(t) - successful(t, dir, env, "task", "test-unit", "GO_TEST_FLAGS=-race -count=1") - if !strings.Contains(logContents(t, dir), "go\ttest\t-race\t-count=1\t./...\t-skip\t^TestAPI$") { - t.Fatal("unit-test alias lost its API-server exclusion") - } -} - -func TestEnterpriseTasks(t *testing.T) { - for _, channel := range []string{"release", "prerelease"} { - t.Run(channel, func(t *testing.T) { - dir, env := fixture(t) - out := successful(t, dir, env, "task", "enterprise:"+channel, "ENTERPRISE_VERSION=0.1.0-rc.1", "DRY_RUN=true") - for _, value := range []string{"task image:push", "task helm:push", "VERSION=0.1.0-rc.1", "manager.image.reference="} { - if !strings.Contains(out, value) { - t.Fatalf("dry run missing %q: %s", value, out) - } - } - if channel == "prerelease" && !strings.Contains(out, "charts/prereleases") { - t.Fatal("wrong prerelease repository") - } - log := logContents(t, dir) - if strings.Contains(log, "helm\t") || strings.Contains(log, "ko\t") { - t.Fatal("dry run executed publishing tools") - } - }) - } -} - -func TestNativeHooks(t *testing.T) { - dir, env := fixture(t) - write(t, filepath.Join(dir, "valid.yaml"), "name: test\n---\nname: second\n") - write(t, filepath.Join(dir, "invalid.yaml"), "name: [broken\n") - write(t, filepath.Join(dir, "duplicate.yaml"), "name: first\nname: second\n") - write(t, filepath.Join(dir, "charts/example/templates/template.yaml"), "{{ if .Values.enabled }}\n") - successful(t, dir, nil, "prek", "validate-config", "prek.toml") - successful(t, dir, env, "prek", "run", "files", "--files", "valid.yaml", "charts/example/templates/template.yaml") - for _, file := range []string{"invalid.yaml", "duplicate.yaml"} { - if out, err := command(t, dir, env, "prek", "run", "files", "--files", file); err == nil || !strings.Contains(out, "Failed") { - t.Fatalf("invalid YAML not rejected: %s, %v", out, err) - } - } - // A hook filename must survive both Devbox and Task argument forwarding. - messageFile := "COMMIT MSG $(touch unexpected)" - for _, message := range []string{"feat: add a maintenance action", "fix(api)!: reject invalid rules\n\nBREAKING CHANGE: validation is stricter"} { - write(t, filepath.Join(dir, messageFile), message+"\n") - successful(t, dir, env, "prek", "run", "conventional-commit", "--hook-stage", "commit-msg", "--commit-msg-filename", messageFile) - } - write(t, filepath.Join(dir, "COMMIT_MSG"), "not a conventional commit\n") - if out, err := command(t, dir, env, "prek", "run", "conventional-commit", "--hook-stage", "commit-msg", "--commit-msg-filename", "COMMIT_MSG"); err == nil || !strings.Contains(out, "Failed") { - t.Fatalf("invalid commit accepted: %s, %v", out, err) - } - if _, err := os.Stat(filepath.Join(dir, "unexpected")); !os.IsNotExist(err) { - t.Fatal("hook filename was evaluated as shell code") - } -} - -func TestHookInstallationReplacesLegacyShims(t *testing.T) { - dir, env := fixture(t) - for _, stage := range []string{"pre-commit", "commit-msg"} { - write(t, filepath.Join(dir, ".git/hooks", stage), "#!/bin/sh\n# OLD_PYTHON_HOOK\nexit 1\n") - } - successful(t, dir, env, "task", "hooks:install") - for _, stage := range []string{"pre-commit", "commit-msg"} { - data, err := os.ReadFile(filepath.Join(dir, ".git/hooks", stage)) - if err != nil || !strings.Contains(string(data), "prek") || strings.Contains(string(data), "OLD_PYTHON_HOOK") { - t.Fatalf("legacy %s hook was not replaced: %v", stage, err) - } - } -} - -func TestDevboxLocks(t *testing.T) { - root := repository(t) - var config struct { - Packages map[string]string `json:"packages"` - } - var lock struct { - Packages map[string]struct { - Resolved string `json:"resolved"` - Version string `json:"version"` - Systems map[string]json.RawMessage `json:"systems"` - } `json:"packages"` - } - for file, destination := range map[string]any{"devbox.json": &config, "devbox.lock": &lock} { - data, err := os.ReadFile(filepath.Join(root, file)) - if err != nil { - t.Fatal(err) - } - if err := json.Unmarshal(data, destination); err != nil { - t.Fatal(err) - } - } - for name, version := range config.Packages { - pkg, found := lock.Packages[name+"@"+version] - if !found || pkg.Resolved == "" || pkg.Version == "" { - t.Fatalf("package %s@%s has no lock entry", name, version) - } - for _, platform := range []string{"aarch64-darwin", "x86_64-linux"} { - if _, found := pkg.Systems[platform]; !found { - t.Errorf("%s has no %s package", name, platform) - } - } - } - module, err := os.ReadFile(filepath.Join(root, "go.mod")) - if err != nil || !strings.Contains(string(module), "\ngo "+config.Packages["go"]+"\n") { - t.Fatal("Devbox Go version differs from the module toolchain") - } -} diff --git a/hack/tooling/webhook_templates_test.go b/hack/tooling/webhook_templates_test.go deleted file mode 100644 index 70cd710..0000000 --- a/hack/tooling/webhook_templates_test.go +++ /dev/null @@ -1,305 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "reflect" - "regexp" - "strings" - "testing" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" - "sigs.k8s.io/yaml" -) - -func webhookTemplateValue(value any) any { - switch value := value.(type) { - case map[string]any: - result := make(map[string]any, len(value)) - for key, entry := range value { - result[key] = webhookTemplateValue(entry) - } - return result - case []any: - result := make([]any, len(value)) - for i, entry := range value { - result[i] = webhookTemplateValue(entry) - } - return result - case nil: - return nil - default: - return fmt.Sprintf("{{ print %q }}", fmt.Sprint(value)) - } -} - -func renderWebhookValues(t *testing.T, values map[string]any) map[string]map[string]any { - t.Helper() - data, err := json.Marshal(values) - if err != nil { - t.Fatal(err) - } - file := filepath.Join(t.TempDir(), "values.json") - write(t, file, string(data)) - out := successful(t, repository(t), nil, "helm", "template", "test", "charts/change-windows", - "--namespace", "platform-system", "--set", "crds.install=false", "--values", file) - result := map[string]map[string]any{} - for _, document := range strings.Split(out, "\n---\n") { - var object map[string]any - if err := yaml.Unmarshal([]byte(document), &object); err != nil { - t.Fatal(err) - } - if object["kind"] != nil { - metadata := object["metadata"].(map[string]any) - result[fmt.Sprint(object["kind"], "/", metadata["name"])] = object - } - } - return result -} - -func TestWebhookTemplating(t *testing.T) { - data, err := os.ReadFile(filepath.Join(repository(t), "charts/change-windows/values.yaml")) - if err != nil { - t.Fatal(err) - } - values := map[string]any{} - if err := yaml.Unmarshal(data, &values); err != nil { - t.Fatal(err) - } - webhooks := values["webhooks"].(map[string]any) - t.Run("default values", func(t *testing.T) { - expected := renderWebhookValues(t, map[string]any{"webhooks": webhooks}) - actual := renderWebhookValues(t, map[string]any{"webhooks": webhookTemplateValue(webhooks)}) - if !reflect.DeepEqual(actual, expected) { - t.Fatal("templating default scalars changed the rendered manifests") - } - }) - webhooks["labels"] = map[string]any{"numeric": "123", "boolean": "false"} - webhooks["annotations"] = map[string]any{"quoted": "a \"quoted\" value", "multiline": "one: 'value'\ntwo: value"} - webhooks["service"] = map[string]any{"port": 8444, "caBundle": "Y2E=", "name": "", "namespace": "", "url": ""} - webhooks["certificate"] = map[string]any{ - "secret": map[string]any{"name": "test-serving"}, - "generate": map[string]any{ - "enabled": true, "dnsNames": []any{"extra.example"}, "ipAddresses": []any{"192.0.2.1"}, - "uris": []any{"spiffe://example/serving"}, "duration": "2160h", "renewBefore": "360h", - }, - } - for _, entry := range webhooks["hooks"].(map[string]any) { - hook := entry.(map[string]any) - hook["failurePolicy"] = "Ignore" - hook["matchPolicy"] = "Equivalent" - hook["timeoutSeconds"] = 11 - hook["objectSelector"] = map[string]any{"matchLabels": map[string]any{"selected": "true"}} - hook["namespaceSelector"] = map[string]any{"matchExpressions": []any{ - map[string]any{"key": "kubernetes.io/metadata.name", "operator": "NotIn", "values": []any{"platform-system"}}, - }} - hook["matchConditions"] = []any{map[string]any{"name": "namespace-filter", "expression": `request.namespace != "platform-system"`}} - } - t.Run("all properties", func(t *testing.T) { - expected := renderWebhookValues(t, map[string]any{"webhooks": webhooks}) - actual := renderWebhookValues(t, map[string]any{"webhooks": webhookTemplateValue(webhooks)}) - if !reflect.DeepEqual(actual, expected) { - t.Fatal("templating webhook properties changed their values or scalar types") - } - service := actual["Service/change-windows-webhook"]["spec"].(map[string]any) - port := service["ports"].([]any)[0].(map[string]any) - if port["port"] != float64(8444) || port["targetPort"] != float64(9443) { - t.Fatalf("service did not use the configured client port and fixed controller port: %v", port) - } - }) -} - -func TestWebhookTemplateContext(t *testing.T) { - out := renderWebhookValues(t, map[string]any{ - "manager": map[string]any{"podAnnotations": map[string]any{"runtime": "{{ .Namespace.Name }}"}}, - "webhooks": map[string]any{ - "labels": map[string]any{"{{ .Release.Name }}/namespace": "{{ .Release.Namespace }}"}, - "annotations": map[string]any{"literal": "{{ \"{{ .Runtime }}\" }}"}, - "certificate": map[string]any{"secret": map[string]any{"name": "{{ include \"change-windows.name\" . }}-serving"}}, - "hooks": map[string]any{"resources": map[string]any{ - "namespaceSelector": map[string]any{"matchExpressions": []any{ - map[string]any{"key": "kubernetes.io/metadata.name", "operator": "NotIn", "values": []any{"{{ .Release.Namespace }}"}}, - }}, - "timeoutSeconds": "{{ .Values.webhooks.hooks.changewindows.timeoutSeconds }}", - }}, - }, - }) - webhook := out["ValidatingWebhookConfiguration/change-windows"] - metadata := webhook["metadata"].(map[string]any) - if metadata["labels"].(map[string]any)["test/namespace"] != "platform-system" || - metadata["annotations"].(map[string]any)["literal"] != "{{ .Runtime }}" { - t.Fatal("root context, templated map keys, or single-pass escaping failed") - } - resource := webhook["webhooks"].([]any)[0].(map[string]any) - selector := resource["namespaceSelector"].(map[string]any)["matchExpressions"].([]any)[0].(map[string]any) - if !reflect.DeepEqual(selector["values"], []any{"platform-system"}) || resource["timeoutSeconds"] != float64(5) { - t.Fatalf("root context did not resolve selectors or values: %v", resource) - } - template := out["Deployment/change-windows"]["spec"].(map[string]any)["template"].(map[string]any) - if template["metadata"].(map[string]any)["annotations"].(map[string]any)["runtime"] != "{{ .Namespace.Name }}" { - t.Fatal("an unrelated runtime template was evaluated") - } - secret := template["spec"].(map[string]any)["volumes"].([]any)[0].(map[string]any)["secret"].(map[string]any)["secretName"] - certificateSecret := out["Certificate/change-windows"]["spec"].(map[string]any)["secretName"] - if secret != "change-windows-serving" || secret != certificateSecret { - t.Fatal("deployment and certificate do not share the rendered serving Secret") - } -} - -func TestWebhookTemplateSwitches(t *testing.T) { - for _, tc := range []struct { - name string - values map[string]any - webhook bool - cert bool - service bool - }{ - {"disabled", map[string]any{"webhooks": map[string]any{"enabled": "{{ false }}"}}, false, false, false}, - {"external", map[string]any{"webhooks": map[string]any{"service": map[string]any{"url": "{{ printf \"https://%s.example:9443/\" .Release.Name }}", "caBundle": "{{ \"ca\" | b64enc }}"}}}, true, false, false}, - {"existing certificate", map[string]any{"webhooks": map[string]any{"certificate": map[string]any{"generate": map[string]any{"enabled": "{{ false }}"}}}}, true, false, true}, - {"exclusive enabled", map[string]any{"crds": map[string]any{"exclusive": true}, "webhooks": map[string]any{"exclusive": "{{ true }}"}}, true, true, true}, - {"exclusive disabled", map[string]any{"crds": map[string]any{"exclusive": true}, "webhooks": map[string]any{"exclusive": "{{ false }}"}}, false, false, false}, - } { - t.Run(tc.name, func(t *testing.T) { - out := renderWebhookValues(t, tc.values) - for kind, want := range map[string]bool{"ValidatingWebhookConfiguration": tc.webhook, "Certificate": tc.cert, "Service": tc.service} { - name := "change-windows" - if kind == "Service" { - name += "-webhook" - } - _, found := out[kind+"/"+name] - if found != want { - t.Errorf("%s present = %v, want %v", kind, found, want) - } - } - if tc.name == "external" { - hooks := out["ValidatingWebhookConfiguration/change-windows"]["webhooks"].([]any) - for i, suffix := range []string{"validate-resources", "validate-changewindow"} { - client := hooks[i].(map[string]any)["clientConfig"].(map[string]any) - if client["url"] != "https://test.example:9443/"+suffix || client["caBundle"] != "Y2E=" || client["service"] != nil { - t.Fatalf("external client config = %v", client) - } - } - } - }) - } -} - -func TestWebhookTemplateErrors(t *testing.T) { - for _, tc := range []struct{ name, values, want string }{ - {"syntax", `webhooks: {labels: {bad: "{{ broken"}}`, "function \"broken\" not defined"}, - {"boolean", `webhooks: {enabled: '{{ print "invalid" }}'}`, "webhooks.enabled must render to true or false"}, - {"port", `webhooks: {service: {port: '{{ print "invalid" }}'}}`, "webhooks.service.port must render to an integer"}, - {"timeout", `webhooks: {hooks: {resources: {timeoutSeconds: '{{ print "1.5" }}'}}}`, "webhooks.hooks.resources.timeoutSeconds must render to an integer"}, - {"overflow", `webhooks: {hooks: {resources: {timeoutSeconds: '{{ print "99999999999999999999" }}'}}}`, "within the signed 64-bit range"}, - {"duplicate keys", `webhooks: {labels: {test: one, '{{ .Release.Name }}': two}}`, "contains duplicate key"}, - } { - t.Run(tc.name, func(t *testing.T) { - file := filepath.Join(t.TempDir(), "values.yaml") - write(t, file, tc.values) - out, err := command(t, repository(t), nil, "helm", "template", "test", "charts/change-windows", "--values", file) - if err == nil || !strings.Contains(out, tc.want) { - t.Fatalf("expected %q, got %v: %s", tc.want, err, out) - } - }) - } -} - -func TestWebhookDocumentationExamples(t *testing.T) { - data, err := os.ReadFile(filepath.Join(repository(t), "charts/change-windows/README.md")) - if err != nil { - t.Fatal(err) - } - section := strings.Split(strings.Split(string(data), "## Admission configuration\n")[1], "\n## Values\n")[0] - examples := regexp.MustCompile("(?s)```yaml\n(.*?)```").FindAllStringSubmatch(section, -1) - if len(examples) != 4 { - t.Fatalf("found %d admission examples, want 4", len(examples)) - } - for i, example := range examples { - t.Run(fmt.Sprint(i), func(t *testing.T) { - values := map[string]any{} - if err := yaml.Unmarshal([]byte(example[1]), &values); err != nil { - t.Fatal(err) - } - out := renderWebhookValues(t, values) - resource := out["ValidatingWebhookConfiguration/change-windows"]["webhooks"].([]any)[0].(map[string]any) - if i == 1 { - selector := resource["namespaceSelector"].(map[string]any)["matchExpressions"].([]any)[0].(map[string]any) - if selector["key"] != "kubernetes.io/metadata.name" || selector["operator"] != "NotIn" || - !reflect.DeepEqual(selector["values"], []any{"platform-system", "kube-system", "kube-public", "kube-node-lease"}) { - t.Fatalf("documented override does not replace tenant selection: %v", selector) - } - } - if i == 2 { - rules := resource["rules"].([]any) - if len(rules) != 1 || rules[0].(map[string]any)["scope"] != "Namespaced" { - t.Fatalf("documented rules still select cluster-scoped resources: %v", rules) - } - } - }) - } -} - -func TestWebhookStandaloneE2ESelection(t *testing.T) { - data, err := os.ReadFile(filepath.Join(repository(t), "charts/change-windows/ci/admission-values.yaml")) - if err != nil { - t.Fatal(err) - } - values := map[string]any{} - if err := yaml.Unmarshal(data, &values); err != nil { - t.Fatal(err) - } - for _, fixture := range []struct { - name string - values map[string]any - }{ - {"defaults", map[string]any{}}, - {"standalone", values}, - } { - t.Run(fixture.name, func(t *testing.T) { - out := renderWebhookValues(t, fixture.values) - resource := out["ValidatingWebhookConfiguration/change-windows"]["webhooks"].([]any)[0].(map[string]any) - encoded, err := json.Marshal(resource["namespaceSelector"]) - if err != nil { - t.Fatal(err) - } - var selector metav1.LabelSelector - if err := json.Unmarshal(encoded, &selector); err != nil { - t.Fatal(err) - } - compiled, err := metav1.LabelSelectorAsSelector(&selector) - if err != nil { - t.Fatal(err) - } - for _, tc := range []struct { - name string - labels labels.Set - want bool - }{ - {"tenant", labels.Set{"capsule.clastix.io/tenant": "tenant-a", "kubernetes.io/metadata.name": "application"}, true}, - {"standalone", labels.Set{"kubernetes.io/metadata.name": "application"}, fixture.name == "standalone"}, - {"system", labels.Set{"kubernetes.io/metadata.name": "kube-system"}, false}, - {"controller", labels.Set{"kubernetes.io/metadata.name": "platform-system"}, false}, - } { - if got := compiled.Matches(tc.labels); got != tc.want { - t.Errorf("%s namespace matched = %v, want %v", tc.name, got, tc.want) - } - } - }) - } - script, err := os.ReadFile(filepath.Join(repository(t), "Taskfile.yml")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(script), "export DEV_HELM_VALUES=charts/change-windows/ci/admission-values.yaml") { - t.Fatal("e2e deployments must use the standalone namespace selector") - } -} diff --git a/hack/tooling/workflows_test.go b/hack/tooling/workflows_test.go deleted file mode 100644 index c4b9e4b..0000000 --- a/hack/tooling/workflows_test.go +++ /dev/null @@ -1,96 +0,0 @@ -//go:build tooling - -// Copyright 2026 Project Capsule Authors -// SPDX-License-Identifier: Apache-2.0 - -package tooling_test - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "sigs.k8s.io/yaml" -) - -func TestWorkflowsUseExistingTaskTargets(t *testing.T) { - root := repository(t) - data, err := os.ReadFile(filepath.Join(root, "Taskfile.yml")) - if err != nil { - t.Fatal(err) - } - var taskfile struct { - Tasks map[string]struct { - Aliases []string `json:"aliases"` - } `json:"tasks"` - } - if err := yaml.Unmarshal(data, &taskfile); err != nil { - t.Fatal(err) - } - targets := map[string]bool{} - for name, task := range taskfile.Tasks { - targets[name] = true - for _, alias := range task.Aliases { - targets[alias] = true - } - } - files, err := filepath.Glob(filepath.Join(root, ".github/workflows/*.yml")) - if err != nil || len(files) == 0 { - t.Fatalf("missing workflows: %v", err) - } - calls := 0 - for _, file := range files { - data, err := os.ReadFile(file) - if err != nil { - t.Fatal(err) - } - for _, obsolete := range []string{"make ", "pip install", "makefile-target:", "charts/capsule", "./internal/controllers/..."} { - if strings.Contains(string(data), obsolete) { - t.Errorf("obsolete command %q in %s", obsolete, file) - } - } - var workflow struct { - Jobs map[string]struct { - Steps []struct { - Run string `json:"run"` - Uses string `json:"uses"` - } `json:"steps"` - } `json:"jobs"` - } - if err := yaml.Unmarshal(data, &workflow); err != nil { - t.Fatal(err) - } - for _, job := range workflow.Jobs { - for _, step := range job.Steps { - if strings.HasPrefix(step.Uses, "./") { - if _, err := os.Stat(filepath.Join(root, step.Uses, "action.yml")); err != nil { - t.Errorf("missing local action %s in %s: %v", step.Uses, file, err) - } - } - for _, line := range strings.Split(step.Run, "\n") { - fields := strings.Fields(line) - for i, field := range fields { - if field != "task" { - continue - } - for _, target := range fields[i+1:] { - // A task may return a reference through shell command substitution. - target = strings.TrimSuffix(target, ")") - if strings.Contains(target, "=") || strings.HasPrefix(target, "-") { - break - } - calls++ - if !targets[target] { - t.Errorf("workflow %s calls missing task %q", file, target) - } - } - } - } - } - } - } - if calls < 20 { - t.Fatalf("expected workflow build/check commands to use Task, found %d calls", calls) - } -} diff --git a/hack/tools/go.mod b/hack/tools/go.mod deleted file mode 100644 index 2557abd..0000000 --- a/hack/tools/go.mod +++ /dev/null @@ -1,50 +0,0 @@ -module github.com/projectcapsule/schedule-action-controller/hack/tools - -go 1.27.1 - -tool github.com/elastic/crd-ref-docs - -require ( - dario.cat/mergo v1.0.2 // indirect - github.com/Masterminds/goutils v1.1.1 // indirect - github.com/Masterminds/semver/v3 v3.4.0 // indirect - github.com/Masterminds/sprig/v3 v3.3.0 // indirect - github.com/elastic/crd-ref-docs v0.3.0 // indirect - github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/go-logr/logr v1.4.3 // indirect - github.com/gobuffalo/flect v1.0.3 // indirect - github.com/goccy/go-yaml v1.19.2 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/huandu/xstrings v1.5.0 // indirect - github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/json-iterator/go v1.1.12 // indirect - github.com/mitchellh/copystructure v1.2.0 // indirect - github.com/mitchellh/reflectwalk v1.0.2 // 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/shopspring/decimal v1.4.0 // indirect - github.com/spf13/cast v1.10.0 // indirect - github.com/spf13/cobra v1.10.2 // indirect - github.com/spf13/pflag v1.0.10 // indirect - github.com/x448/float16 v0.8.4 // indirect - go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.1 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/crypto v0.47.0 // indirect - golang.org/x/mod v0.32.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sync v0.19.0 // indirect - golang.org/x/text v0.33.0 // indirect - golang.org/x/tools v0.41.0 // indirect - gopkg.in/inf.v0 v0.9.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/apiextensions-apiserver v0.35.0 // indirect - k8s.io/apimachinery v0.35.0 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 // indirect - k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect - sigs.k8s.io/controller-tools v0.20.0 // indirect - sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect - sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect -) diff --git a/hack/tools/go.sum b/hack/tools/go.sum deleted file mode 100644 index 6e28679..0000000 --- a/hack/tools/go.sum +++ /dev/null @@ -1,231 +0,0 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= -github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= -github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= -github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= -github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= -github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= -github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= -github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= -github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= -github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/elastic/crd-ref-docs v0.3.0 h1:9bGSUkBR56Z7TuDGQAu3KGbBkagwwZ6RkZmS+qvDuDM= -github.com/elastic/crd-ref-docs v0.3.0/go.mod h1:8td3UC8CaO5M+G115O3FRKLmplmX+p0EqLMLGM6uNdk= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= -github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= -github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= -github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= -github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= -github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= -github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= -github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= -github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= -github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= -github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= -github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= -github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= -github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= -github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= -github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= -github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= -github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= -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.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= -github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= -github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= -github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= -github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= -github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= -github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= -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/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= -github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= -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/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= -github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= -github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= -github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= -github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= -github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= -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/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= -github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= -github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= -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/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= -github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= -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/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= -github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= -go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= -go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= -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.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= -go.uber.org/multierr v1.11.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/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= -golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= -golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= -golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= -golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= -golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= -golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950= -google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8= -google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM= -google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= -google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= -gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= -k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= -k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= -k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= -k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= -k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= -k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= -k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= -k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= -k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= -k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= -k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= -k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-tools v0.20.0 h1:VWZF71pwSQ2lZZCt7hFGJsOfDc5dVG28/IysjjMWXL8= -sigs.k8s.io/controller-tools v0.20.0/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= -sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= -sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= -sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= -sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/admission/wildcard_test.go b/internal/admission/wildcard_test.go index e8140d6..312d5da 100644 --- a/internal/admission/wildcard_test.go +++ b/internal/admission/wildcard_test.go @@ -21,7 +21,7 @@ import ( ) func TestFreezeExceptPodsExample(t *testing.T) { - data, err := os.ReadFile("../../examples/freeze-except-pods.yaml") + data, err := os.ReadFile("../../playground/manifests/examples/freeze-except-pods.yaml") if err != nil { t.Fatal(err) } diff --git a/internal/policy/policy_test.go b/internal/policy/policy_test.go index 3813a75..10bb9ec 100644 --- a/internal/policy/policy_test.go +++ b/internal/policy/policy_test.go @@ -5,12 +5,15 @@ package policy import ( "fmt" + "os" + "path/filepath" "slices" "sync" "testing" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" api "github.com/projectcapsule/schedule-action-controller/api/v1alpha1" "github.com/projectcapsule/schedule-action-controller/internal/testutil" @@ -135,3 +138,39 @@ func TestRenderLimits(t *testing.T) { t.Fatal("output limit ignored") } } + +func TestChangeWindowExamples(t *testing.T) { + files, err := filepath.Glob("../../playground/manifests/examples/*.yaml") + if err != nil || len(files) == 0 { + t.Fatalf("no examples found: %v", err) + } + count := 0 + for _, file := range files { + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + var kind struct { + Kind string `json:"kind"` + } + if err := yaml.Unmarshal(data, &kind); err != nil { + t.Fatal(err) + } + if kind.Kind != "ChangeWindow" { + continue + } + count++ + t.Run(filepath.Base(file), func(t *testing.T) { + var action api.ChangeWindow + if err := yaml.UnmarshalStrict(data, &action); err != nil { + t.Fatal(err) + } + if _, err := Compile(&action, testutil.Mapper()); err != nil { + t.Fatalf("documented example fails policy validation: %v", err) + } + }) + } + if count == 0 { + t.Fatal("no ChangeWindow examples found") + } +} diff --git a/playground/README.md b/playground/README.md new file mode 100644 index 0000000..8ddc3c0 --- /dev/null +++ b/playground/README.md @@ -0,0 +1,52 @@ +# Change Windows playground + +Build the current controller and Headlamp plugin in a disposable Kind cluster: + +```sh +# From the repository root, with Docker running: +devbox run -- task playground +``` + +Headlamp is available at after setup. Flux installs and +reconciles Headlamp through a HelmRelease; the operator uses the local Helm chart +and a ko image loaded into Kind. The task deletes its cluster after 30 minutes or +when stopped with Ctrl+C, preserving other clusters and your kubeconfig context. +Use `PLAYGROUND_MINUTES=10`, `PLAYGROUND_PORT=18082`, or +`PLAYGROUND_CLUSTER=my-playground` to customize the run. + +## Layout + +| Path | Purpose | +| --- | --- | +| `kind.config.yaml` | Kind configuration, including the read-only local plugin mount. | +| `installation/headlamp/` | Flux HelmRepository, HelmRelease, and Kustomization. | +| `installation/change-windows/values.yaml` | Standalone demo Helm values; admission selects only `development`. | +| `manifests/kustomization.yaml` | Selects the runnable demo and its executor permissions. | +| `manifests/demo/` | Demo namespace, workload, Headlamp permissions, and cycling ChangeWindow. | +| `manifests/examples/` | Individual documented ChangeWindows and executor RBAC examples. | +| `manifests/dev/` | Dry-run admission probe used by `task dev:deploy`. | + +Kind resolves `hostPath: ./plugins` against its working directory. The task stages +`headlamp/dist` as `plugins/capsule-change-windows/` in a private temporary directory +and runs Kind there with the checked-in configuration. No generated Kind template +or host-path substitution is needed. Flux's source and Helm controllers are installed +in `flux-system`; the Headlamp release runs in `kube-system`. + +Preview the resources without a cluster: + +```sh +kubectl kustomize playground/installation/headlamp +kubectl kustomize playground/manifests +``` + +Apply the demo through its Kustomization, rather than recursively applying every +sample. The other examples are opt-in and can have overlapping schedules or +policies. The documentation mounts `manifests/examples/` directly, so examples +remain the same files used by the policy tests and documentation downloads. + +Headlamp uses a shared, limited demo identity over a localhost-only connection. +It can edit ChangeWindows and the demo workloads but cannot read controller journal +Secrets or administer RBAC. Keep this playground private. + +See the [playground guide](../docs/content/docs/development/playground.md) for the +maintenance cycle, permissions, and cleanup behavior. diff --git a/hack/playground/operator-values.yaml b/playground/installation/change-windows/values.yaml similarity index 100% rename from hack/playground/operator-values.yaml rename to playground/installation/change-windows/values.yaml diff --git a/playground/installation/headlamp/kustomization.yaml b/playground/installation/headlamp/kustomization.yaml new file mode 100644 index 0000000..7fcbf10 --- /dev/null +++ b/playground/installation/headlamp/kustomization.yaml @@ -0,0 +1,4 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - release.flux.yaml diff --git a/playground/installation/headlamp/release.flux.yaml b/playground/installation/headlamp/release.flux.yaml new file mode 100644 index 0000000..0730e12 --- /dev/null +++ b/playground/installation/headlamp/release.flux.yaml @@ -0,0 +1,55 @@ +apiVersion: source.toolkit.fluxcd.io/v1 +kind: HelmRepository +metadata: + name: headlamp-charts + namespace: flux-system +spec: + interval: 24h + url: https://kubernetes-sigs.github.io/headlamp/ +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: headlamp + namespace: flux-system +spec: + interval: 10m + timeout: 3m + releaseName: headlamp + targetNamespace: kube-system + chart: + spec: + chart: headlamp + version: '0.45.0' + sourceRef: + kind: HelmRepository + name: headlamp-charts + interval: 24h + install: + remediation: + retries: 2 + upgrade: + remediation: + retries: 2 + driftDetection: + mode: enabled + values: + # Only for the disposable playground, reached through localhost or private Tailscale. + # All visitors share the limited demo identity. Never expose this on a public URL. + fullnameOverride: headlamp + config: + unsafeUseServiceAccountToken: true + pluginsDir: /headlamp/plugins + clusterRoleBinding: + create: false + serviceAccount: + name: headlamp + volumes: + - name: local-plugin + hostPath: + path: /opt/change-windows-plugins + type: Directory + volumeMounts: + - name: local-plugin + mountPath: /headlamp/plugins + readOnly: true diff --git a/playground/kind.config.yaml b/playground/kind.config.yaml new file mode 100644 index 0000000..ee2b1b9 --- /dev/null +++ b/playground/kind.config.yaml @@ -0,0 +1,12 @@ +apiVersion: kind.x-k8s.io/v1alpha4 +kind: Cluster +networking: + apiServerAddress: 127.0.0.1 +nodes: + - role: control-plane + extraMounts: + # Kind resolves this relative to its working directory. The playground task + # stages the current plugin in a private directory and creates Kind from there. + - hostPath: ./plugins + containerPath: /opt/change-windows-plugins + readOnly: true diff --git a/hack/playground/resources.yaml b/playground/manifests/demo/resources.yaml similarity index 100% rename from hack/playground/resources.yaml rename to playground/manifests/demo/resources.yaml diff --git a/hack/playground/window.yaml b/playground/manifests/demo/window.yaml similarity index 100% rename from hack/playground/window.yaml rename to playground/manifests/demo/window.yaml diff --git a/hack/dev/readiness.yaml b/playground/manifests/dev/readiness.yaml similarity index 100% rename from hack/dev/readiness.yaml rename to playground/manifests/dev/readiness.yaml diff --git a/examples/addons_v1alpha1_changewindow.yaml b/playground/manifests/examples/addons_v1alpha1_changewindow.yaml similarity index 100% rename from examples/addons_v1alpha1_changewindow.yaml rename to playground/manifests/examples/addons_v1alpha1_changewindow.yaml diff --git a/examples/after-hours-executor-rbac.yaml b/playground/manifests/examples/after-hours-executor-rbac.yaml similarity index 100% rename from examples/after-hours-executor-rbac.yaml rename to playground/manifests/examples/after-hours-executor-rbac.yaml diff --git a/examples/after-hours.yaml b/playground/manifests/examples/after-hours.yaml similarity index 100% rename from examples/after-hours.yaml rename to playground/manifests/examples/after-hours.yaml diff --git a/examples/conditional-maintenance.yaml b/playground/manifests/examples/conditional-maintenance.yaml similarity index 100% rename from examples/conditional-maintenance.yaml rename to playground/manifests/examples/conditional-maintenance.yaml diff --git a/examples/executor-rbac.yaml b/playground/manifests/examples/executor-rbac.yaml similarity index 100% rename from examples/executor-rbac.yaml rename to playground/manifests/examples/executor-rbac.yaml diff --git a/examples/freeze-except-pods.yaml b/playground/manifests/examples/freeze-except-pods.yaml similarity index 100% rename from examples/freeze-except-pods.yaml rename to playground/manifests/examples/freeze-except-pods.yaml diff --git a/examples/maintenance-only.yaml b/playground/manifests/examples/maintenance-only.yaml similarity index 100% rename from examples/maintenance-only.yaml rename to playground/manifests/examples/maintenance-only.yaml diff --git a/examples/node-pool-maintenance.yaml b/playground/manifests/examples/node-pool-maintenance.yaml similarity index 100% rename from examples/node-pool-maintenance.yaml rename to playground/manifests/examples/node-pool-maintenance.yaml diff --git a/examples/quickstart.yaml b/playground/manifests/examples/quickstart.yaml similarity index 100% rename from examples/quickstart.yaml rename to playground/manifests/examples/quickstart.yaml diff --git a/playground/manifests/kustomization.yaml b/playground/manifests/kustomization.yaml new file mode 100644 index 0000000..019becb --- /dev/null +++ b/playground/manifests/kustomization.yaml @@ -0,0 +1,7 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +# Install only the runnable demo. Other examples are opt-in and may overlap. +resources: + - demo/resources.yaml + - examples/executor-rbac.yaml + - demo/window.yaml diff --git a/prek.toml b/prek.toml index e7e71a3..bcc9738 100644 --- a/prek.toml +++ b/prek.toml @@ -3,6 +3,18 @@ default_install_hook_types = ["pre-commit", "commit-msg"] default_stages = ["pre-commit"] exclude = '^(\.devbox/|\.task/|bin/|dist/|artifacts/|builds/)' +[[repos]] +repo = "builtin" +hooks = [ + { id = "check-executables-have-shebangs", groups = ["files"], priority = 10 }, + { id = "end-of-file-fixer", args = ["--check"], groups = ["files"], priority = 10 }, + { id = "trailing-whitespace", args = ["--check"], groups = ["files"], priority = 10 }, + { id = "check-merge-conflict", groups = ["files"], priority = 10 }, + { id = "check-json", groups = ["files"], priority = 10 }, + { id = "check-toml", groups = ["files"], priority = 10 }, + { id = "check-yaml", args = ["--allow-multiple-documents"], exclude = '^charts/[^/]+/templates/', groups = ["files"], priority = 10 }, +] + [[repos]] repo = "local" @@ -11,7 +23,7 @@ id = "generated" name = "Generate API code and manifests" entry = "devbox run -- task generate manifests" language = "system" -files = '^(api/|internal/|go\.(mod|sum)$|hack/boilerplate.go.txt$)' +files = '^(api/|internal/|go\.(mod|sum)$)' pass_filenames = false require_serial = true priority = 0 @@ -52,21 +64,12 @@ files = '^\.github/workflows/.*\.ya?ml$' pass_filenames = false priority = 20 -[[repos.hooks]] -id = "shellcheck" -name = "Check inline Task shell commands" -entry = "devbox run -- task lint:shell" -language = "system" -files = '^(Taskfile\.yml|hack/tooling/.*\.go)$' -pass_filenames = false -priority = 20 - [[repos.hooks]] id = "docs" name = "Validate documentation and generated API reference" entry = "devbox run -- task docs:check" language = "system" -files = '^(docs/|api/|examples/|hack/docs/|Taskfile\.yml$|hack/tools/)' +files = '^(docs/|api/|playground/manifests/|charts/change-windows/crds/|Taskfile\.yml$|devbox\.(json|lock)$)' pass_filenames = false priority = 40 @@ -77,10 +80,3 @@ entry = "devbox run -- task lint:commit-message --" language = "system" stages = ["commit-msg"] priority = 10 - -[[repos.hooks]] -id = "files" -name = "Check file hygiene and syntax" -entry = "devbox run -- task lint:files --" -language = "system" -priority = 10 diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..d709b7c --- /dev/null +++ b/renovate.json @@ -0,0 +1,75 @@ +{ + "minimumReleaseAge": null, + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": ["config:recommended", ":dependencyDashboard", "helpers:pinGitHubActionDigests"], + "baseBranchPatterns": ["main"], + "prHourlyLimit": 0, + "prConcurrentLimit": 0, + "branchConcurrentLimit": 0, + "mode": "full", + "commitMessageLowerCase": "auto", + "semanticCommits": "enabled", + "semanticCommitType": "feat", + "ignorePaths": ["docs"], + "labels": ["renovate"], + "devbox": { + "enabled": true, + "lockFileMaintenance": { + "enabled": true + } + }, + "flux": { + "managerFilePatterns": ["/^.*flux\\.yaml$/"] + }, + "packageRules": [ + { + "matchFileNames": ["devbox.json"], + "groupName": "devbox-toolchain" + }, + { + "matchManagers": ["github-actions", "flux"], + "groupName": "all-ci-updates", + "matchUpdateTypes": ["major", "minor", "patch"] + }, + { + "matchManagers": ["github-actions"], + "matchPackageNames": ["sigstore/cosign-installer"], + "enabled": false + }, + { + "description": "grc.store requires the trusted publisher's v1 tag in its OIDC identity", + "matchManagers": ["github-actions"], + "matchPackageNames": ["revanite-io/pvtr-publish-results"], + "pinDigests": false, + "enabled": false + }, + { + "matchManagers": [ + "github-actions", + "gomod", + "dockerfile", + "docker-compose" + ], + "minimumReleaseAge": "14 days" + }, + { + "matchManagers": ["helmv3", "helm-values", "flux"], + "minimumReleaseAge": null + } + ], + "customManagers": [ + { + "customType": "regex", + "description": "Update crdoc installed by the Devbox init hook", + "managerFilePatterns": [ + "/^devbox\\.json$/" + ], + "matchStrings": [ + "\"CRDOC_VERSION\"\\s*:\\s*\"(?[^\"]+)\"" + ], + "depNameTemplate": "fybrik.io/crdoc", + "datasourceTemplate": "gomod", + "versioningTemplate": "go" + } + ] +}