diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b44a605..758bdf3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,7 @@ on: permissions: contents: read - pull-requests: read + pull-requests: write jobs: @@ -130,5 +130,54 @@ jobs: go mod tidy make test-e2e + helm-chart-reminder: + name: Helm Chart Update Reminder + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Check for Helm-relevant changes + id: changes + run: | + DIFF="$(git diff origin/${{ github.base_ref }}...HEAD)" + REASONS="" + if echo "$DIFF" | grep -qE '^\+.*\+kubebuilder:rbac'; then + REASONS="${REASONS}\n- RBAC markers (\`+kubebuilder:rbac\`) were added or modified → update RBAC template" + fi + if echo "$DIFF" | grep -qE '^\+.*\+kubebuilder:webhook'; then + REASONS="${REASONS}\n- Webhook markers (\`+kubebuilder:webhook\`) were added or modified → update webhook configuration template" + fi + if git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -q 'config/manager/manager.yaml'; then + REASONS="${REASONS}\n- \`config/manager/manager.yaml\` was modified → update deployment template (env vars, args, ports, volumes)" + fi + if [[ -n "$REASONS" ]]; then + echo "changed=true" >> "$GITHUB_OUTPUT" + # Use delimiter for multiline output + echo "reasons<> "$GITHUB_OUTPUT" + echo -e "$REASONS" >> "$GITHUB_OUTPUT" + echo "EOF" >> "$GITHUB_OUTPUT" + else + echo "changed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Comment on PR about Helm chart changes + if: steps.changes.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + run: | + COMMENT_MARKER="" + EXISTING=$(gh pr view ${{ github.event.pull_request.number }} --json comments --jq '[.comments[].body | select(contains("'"$COMMENT_MARKER"'"))] | length') + if [[ "$EXISTING" == "0" ]]; then + gh pr comment ${{ github.event.pull_request.number }} --body "${COMMENT_MARKER} + ⚠️ **Helm Chart Update Required** + + This PR contains changes that likely require a matching update in [git-hubby-helm](https://github.com/Interhyp/git-hubby-helm): + ${{ steps.changes.outputs.reasons }} + + After merging, run \`make manifests\` and compare the generated output in \`config/\` with the corresponding Helm chart templates." + fi diff --git a/.github/workflows/helm-chart-update.yml b/.github/workflows/helm-chart-update.yml index 184d9f0..c80e8f0 100644 --- a/.github/workflows/helm-chart-update.yml +++ b/.github/workflows/helm-chart-update.yml @@ -14,10 +14,11 @@ env: REGISTRY: ghcr.io IMAGE_NAME: ${{ github.repository }} HELM_REPO: Interhyp/git-hubby-helm + CHART_DIR: helm-chart jobs: update-helm-chart: - name: Update Helm Chart + name: Update Helm Chart CRDs runs-on: ubuntu-latest if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }} steps: @@ -43,9 +44,6 @@ jobs: with: go-version-file: go.mod - - name: Lowercase image name - run: echo "IMAGE_NAME=${IMAGE_NAME,,}" >> "$GITHUB_ENV" - - name: Determine image version id: version run: | @@ -66,69 +64,78 @@ jobs: echo "version=${VERSION}" >> "$GITHUB_OUTPUT" echo "Image version: ${VERSION}" + - name: Generate CRDs + run: make manifests + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.HELM_APP_ID }} + private-key: ${{ secrets.HELM_APP_PRIVATE_KEY }} + owner: Interhyp + repositories: git-hubby-helm + - name: Checkout Helm chart repository uses: actions/checkout@v6 with: repository: ${{ env.HELM_REPO }} - token: ${{ secrets.HELM_CHART_PAT }} - path: helm-repo - - - name: Generate Helm chart - run: make helm CHART_DIR=helm-repo/chart - - - name: Update image tag in Helm chart - run: | - if [[ -f helm-repo/chart/values.yaml ]]; then - sed -i "s|tag:.*|tag: \"${{ steps.version.outputs.version }}\"|" helm-repo/chart/values.yaml - sed -i "s|repository:.*|repository: ${REGISTRY}/${IMAGE_NAME}|" helm-repo/chart/values.yaml - fi - if [[ -f helm-repo/chart/Chart.yaml ]]; then - sed -i "s|^appVersion:.*|appVersion: \"${{ steps.version.outputs.version }}\"|" helm-repo/chart/Chart.yaml - fi + token: ${{ steps.app-token.outputs.token }} + path: ${{ env.CHART_DIR }} - name: Determine target branch id: branch run: | if [[ "${{ steps.source.outputs.branch }}" == "main" ]]; then - echo "name=update/v${{ steps.version.outputs.version }}" >> "$GITHUB_OUTPUT" + echo "name=crd-update/v${{ steps.version.outputs.version }}" >> "$GITHUB_OUTPUT" else - BRANCH="$(echo '${{ steps.source.outputs.branch }}' | sed 's|[^a-zA-Z0-9._-]|-|g' | cut -c1-50)" - echo "name=snapshot/${BRANCH}" >> "$GITHUB_OUTPUT" + echo "name=crd-update/${{ steps.source.outputs.branch }}" >> "$GITHUB_OUTPUT" fi + - name: Checkout or create target branch + working-directory: ${{ env.CHART_DIR }} + run: | + git fetch origin "${{ steps.branch.outputs.name }}" 2>/dev/null && \ + git checkout "${{ steps.branch.outputs.name }}" || \ + git checkout -b "${{ steps.branch.outputs.name }}" + + - name: Update CRDs in Helm chart + run: cp config/crd/bases/*.yaml ${{ env.CHART_DIR }}/crds/ + + - name: Update appVersion in Helm chart + run: | + if [[ -f ${{ env.CHART_DIR }}/Chart.yaml ]]; then + sed -i "s|^appVersion:.*|appVersion: \"${{ steps.version.outputs.version }}\"|" ${{ env.CHART_DIR }}/Chart.yaml + fi - name: Commit and push changes - working-directory: helm-repo + working-directory: ${{ env.CHART_DIR }} run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - git checkout -b "${{ steps.branch.outputs.name }}" git add -A git diff --cached --quiet && echo "No changes to commit" && exit 0 - git commit -m "chore: update helm chart to version ${{ steps.version.outputs.version }}" - git push --force origin "${{ steps.branch.outputs.name }}" + git commit -m "feat: update CRDs to version ${{ steps.version.outputs.version }}" + git push origin "${{ steps.branch.outputs.name }}" - name: Create Pull Request for main branch if: steps.source.outputs.branch == 'main' env: - GH_TOKEN: ${{ secrets.HELM_CHART_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | - cd helm-repo + cd ${{ env.CHART_DIR }} EXISTING_PR=$(gh pr list --head "${{ steps.branch.outputs.name }}" --json number --jq '.[0].number' 2>/dev/null || true) if [[ -n "$EXISTING_PR" ]]; then echo "PR #${EXISTING_PR} already exists, updating..." gh pr edit "$EXISTING_PR" \ - --title "chore: update helm chart to v${{ steps.version.outputs.version }}" \ - --body "Automated Helm chart update from [git-hubby release v${{ steps.version.outputs.version }}](https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }})" + --title "feat: update CRDs to v${{ steps.version.outputs.version }}" \ + --body "Automated CRD update from [git-hubby release v${{ steps.version.outputs.version }}](https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }})" else gh pr create \ --repo "${{ env.HELM_REPO }}" \ --head "${{ steps.branch.outputs.name }}" \ --base main \ - --title "chore: update helm chart to v${{ steps.version.outputs.version }}" \ - --body "Automated Helm chart update from [git-hubby release v${{ steps.version.outputs.version }}](https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }})" \ - --label "automatic-update" \ - --draft + --title "feat: update CRDs to v${{ steps.version.outputs.version }}" \ + --body "Automated CRD update from [git-hubby release v${{ steps.version.outputs.version }}](https://github.com/${{ github.repository }}/releases/tag/v${{ steps.version.outputs.version }})" \ + --label "automatic-update" fi - - diff --git a/.gitignore b/.gitignore index c20acc5..ab276d8 100644 --- a/.gitignore +++ b/.gitignore @@ -68,9 +68,3 @@ build-output/ node_modules/ -# Generated Helm chart (output of make helm) -chart/ - -# post-helmify binary (built by go build ./hack/post-helmify/) -/post-helmify - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dc9c234..fbc4fd9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -79,7 +79,6 @@ Run `make help` for the full list. The most important targets are: | `make lint-fix` | Lint with auto-fix | | `make generate` | Regenerate deepcopy and apply-configuration code | | `make manifests` | Regenerate CRDs and RBAC from kubebuilder markers | -| `make helm` | Regenerate Helm chart from Kustomize manifests | | `make install` | Install CRDs into the current cluster | | `make deploy IMG=` | Deploy the operator to the current cluster | | `make undeploy` | Remove the operator from the current cluster | @@ -135,7 +134,6 @@ kubebuilder create webhook --group github --version v1alpha1 --kind --def │ ├── conditions/ Status condition helpers │ └── logging/ Log mapping utilities ├── config/ Kustomize manifests (mostly auto-generated) -├── chart/ Helm chart (generated via helmify) └── test/ E2E and integration tests ``` @@ -280,10 +278,10 @@ make manifests generate crd-docs ### Helm Chart Update -The **Update Helm Chart** workflow manages the Helm chart in [Interhyp/git-hubby-helm](https://github.com/Interhyp/git-hubby-helm): +The **Update Helm Chart** workflow manages CRD updates in [Interhyp/git-hubby-helm](https://github.com/Interhyp/git-hubby-helm): -- **Automatic (main only)**: After a successful release on `main`, the workflow regenerates the Helm chart, updates the image tag to the released version, pushes a branch to `git-hubby-helm`, and creates a draft PR labeled `automatic-update`. -- **Manual (any branch)**: You can trigger the workflow manually via `workflow_dispatch` to test Helm chart generation from your feature branch. The result is pushed to a `snapshot/` branch in `git-hubby-helm` (no PR is created). +- **Automatic (after release)**: After a successful "Build & Release" workflow, CRDs are copied to the helm chart repo with an updated `appVersion`, and a draft PR is created (main only) labeled `automatic-update`. +- **Manual (any branch)**: You can trigger the workflow manually via `workflow_dispatch` to test CRD updates from your feature branch. The result is pushed to a `snapshot/` branch in `git-hubby-helm` (no PR is created). To manually trigger from your branch: @@ -291,7 +289,10 @@ To manually trigger from your branch: gh workflow run "Update Helm Chart" --ref ``` -This lets you verify Helm chart changes before merging to `main`. +> **Note**: Only CRDs are updated automatically. The Helm chart's other templates (deployment, RBAC, webhooks) are maintained manually. The CI workflow will comment on your PR if it detects changes that require a matching Helm chart update: +> - `+kubebuilder:rbac` markers → RBAC template +> - `+kubebuilder:webhook` markers → webhook configuration template +> - `config/manager/manager.yaml` → deployment template (env vars, args, ports, volumes) ### Commit Message Format diff --git a/Makefile b/Makefile index 1b13d5d..4b8f073 100644 --- a/Makefile +++ b/Makefile @@ -285,22 +285,6 @@ ginkgo: $(GINKGO) ## Download ginkgo CLI locally if necessary. $(GINKGO): $(LOCALBIN) $(call go-install-tool,$(GINKGO),github.com/onsi/ginkgo/v2/ginkgo,$(GINKGO_VERSION)) -# Generate helm chart from kustomize using helmify - -HELMIFY ?= $(LOCALBIN)/helmify - -.PHONY: helmify -helmify: $(HELMIFY) ## Download helmify locally if necessary. -$(HELMIFY): $(LOCALBIN) - test -s $(LOCALBIN)/helmify || GOBIN=$(LOCALBIN) go install github.com/arttor/helmify/cmd/helmify@latest - -CHART_DIR ?= chart - -.PHONY: helm -helm: manifests generate kustomize crd-docs helmify - $(KUSTOMIZE) build config/default | $(HELMIFY) -crd-dir $(CHART_DIR) && \ - go run ./hack/post-helmify $(CHART_DIR) - # Generate CRD Documentation using crd-ref-docs CRD_REF_DOCS ?= $(LOCALBIN)/crd-ref-docs diff --git a/docs/techdocs/packaging.md b/docs/techdocs/packaging.md index 246c9ec..a790598 100644 --- a/docs/techdocs/packaging.md +++ b/docs/techdocs/packaging.md @@ -1,176 +1,71 @@ # Packaging & Deployment: Kustomize and Helm -This document explains how git-hubby is packaged for deployment, the relationship between the Kustomize and Helm workflows, and the key differences between them — especially around namespace configuration. +This document explains how git-hubby is packaged for deployment and the relationship between the Kustomize and Helm workflows. ## Overview git-hubby supports two deployment methods: -| Method | Primary Use | Namespace Model | Source of Truth | -|--------|-------------|-----------------|-----------------| -| **Kustomize** (`make deploy`) | Local development, CI/CD testing | Single namespace | `config/` manifests | -| **Helm** (`helm install`) | Production, staging | Multi-namespace (configurable) | `chart/` (generated from Kustomize) | +| Method | Primary Use | Source of Truth | +|--------|-------------|-----------------| +| **Kustomize** (`make deploy`) | Local development, CI/CD testing | `config/` manifests | +| **Helm** (`helm install`) | Production, staging | [Interhyp/git-hubby-helm](https://github.com/Interhyp/git-hubby-helm) | -> **Important**: The Helm chart is **generated** from Kustomize manifests. Kustomize is always the upstream source. Manual edits to `chart/` will be overwritten by `make helm`. +> **Important**: The Helm chart is maintained as a separate repository. Only CRDs are automatically synced from this repo via CI. All other Helm templates (deployment, RBAC, webhooks, services) are maintained manually in the Helm chart repo. -## The Kustomize → Helm Pipeline +## Automated CRD Sync -``` - config/ Kustomize helmify post-helmify (Go) chart/ - (base manifests) ──► build ─────────────► (generates chart) ──► (patches chart) ──► (final Helm chart) - │ │ │ - │ ├─ applies namespace: git-hubby-system ├─ restores production defaults - │ ├─ applies namePrefix: git-hubby- ├─ templates namespaces - │ └─ applies strategic merge patches └─ adds Helm-specific features - │ - └─ config/default/kustomization.yaml orchestrates everything -``` - -### Step-by-step - -1. **`make helm`** runs `make manifests generate` first (regenerates CRDs, RBAC, deepcopy from Go markers). -2. **Kustomize builds** `config/default/` into a single YAML stream. This applies: - - Global namespace (`git-hubby-system`) to all resources - - Name prefixing (`git-hubby-`) - - Strategic merge patches (metrics, webhooks, watch namespace alignment) - - Cert-manager replacements -3. **Helmify** consumes the fully-rendered kustomize output and generates `chart/` (templates, values.yaml, CRDs). -4. **`go run ./hack/post-helmify`** runs and applies corrections that helmify cannot handle: - - Introduces `controllerManager.watchedNamespaces` list in `values.yaml` (replacing the helmify-generated `watchNamespace` string) - - Templates `APP_CREDENTIALS_SECRET_NAMESPACE` with a Helm expression defaulting to release namespace - - Copies pre-built multi-namespace RBAC template from `config/tmp/manager-rbac.yaml` - - Adds namespace to app-credentials Role/RoleBinding - - Replaces webhook `namespaceSelector` with the multi-namespace-aware helper - - Adds pod labels, service account secrets/labels support - - Copies values-driven serving-cert template from `config/tmp/serving-cert.yaml` - - Adds various defaults to `values.yaml` (`podLabels`, `servingCert`, etc.) - -The post-helmify tool is a Go binary at `hack/post-helmify/` with full test coverage. Each transformation is idempotent (safe to run multiple times) and fails fast with a clear error message including the step name. - -### What NOT to Edit - -| Location | Reason | -|----------|--------| -| `chart/templates/*.yaml` (most files) | Overwritten by `make helm` | -| `chart/values.yaml` | Overwritten by `make helm` | -| `chart/crds/*.yaml` | Overwritten by `make helm` | -| `config/crd/bases/*.yaml` | Generated by `make manifests` | -| `config/rbac/role.yaml` | Generated by `make manifests` from RBAC markers | - -### What TO Edit - -| File | Purpose | -|------|---------| -| `config/manager/manager.yaml` | Base deployment manifest (env vars, image, resources) | -| `config/default/kustomization.yaml` | Kustomize overlay configuration | -| `config/default/*_patch.yaml` | Kustomize strategic merge patches | -| `hack/post-helmify/` | Post-generation Go binary that patches the Helm chart | -| `hack/post-helmify/templates/*.yaml` | Pre-built Helm templates copied by post-helmify | -| `chart/templates/_helpers.tpl` | Helm template helpers (manually maintained) | -| `internal/controller/*_controller.go` | RBAC markers that generate `config/rbac/role.yaml` | - -## Namespace Architecture - -### The Namespace Problem - -git-hubby intentionally separates concerns across namespaces: - -- **Controller namespace**: Where the operator pod runs (e.g., `git-hubby-system`) -- **Watch namespace**: Where CRDs (Organization, Repository, Team) live (e.g., `github-configuration`) -- **Credentials namespace**: Where the GitHub App credentials secret lives (e.g., `git-hubby-system` or elsewhere) - -This separation requires cross-namespace RBAC: the controller's service account in the controller namespace needs a Role + RoleBinding in the watch namespace to read/write CRDs, and another Role + RoleBinding in the credentials namespace to read secrets. +The **Update Helm Chart** workflow automatically copies generated CRDs to the Helm chart repo: -### Kustomize: Single-Namespace Model - -Kustomize's global `namespace` transformer moves **all** resources into a single namespace (`git-hubby-system`). This includes RBAC resources that the RBAC markers originally target at `github-configuration`: - -```yaml -# config/default/kustomization.yaml -namespace: git-hubby-system # Overrides ALL resource namespaces ``` - -Because of this, the kustomize flow collapses everything into one namespace. To keep `WATCH_NAMESPACE` aligned with where the Role actually lands, a kustomize patch overrides the env var: - -```yaml -# config/default/manager_watch_namespace_patch.yaml -# Overrides WATCH_NAMESPACE from "github-configuration" to "git-hubby-system" + config/crd/bases/*.yaml ──► git-hubby-helm/crds/*.yaml ``` -Similarly, the webhook `namespaceSelector` is patched to match `git-hubby-system`: +This runs after every successful "Build & Release" workflow. For main branch releases, a PR is created in the Helm chart repo. -```yaml -# config/default/webhook_namespace_selector_patch.yaml -``` - -**Result**: In the kustomize flow, everything (controller, CRDs, RBAC, secrets) lives in `git-hubby-system`. This is simple and suitable for development/testing. +## What Requires Manual Helm Chart Updates -### Helm: Multi-Namespace Model +| Change in this repo | Helm chart file to update | +|---|---| +| `+kubebuilder:rbac` markers | RBAC templates (roles) | +| `+kubebuilder:webhook` markers | `validating-webhook-configuration.yaml` | +| `config/manager/manager.yaml` (env vars, args, ports) | `deployment.yaml` | +| New CRD types (types.go) | CRDs updated automatically; may need RBAC + webhook updates | -The Helm chart supports deploying across multiple namespaces via `values.yaml`: +The CI workflow comments on PRs when it detects these changes. -```yaml -controllerManager: - watchedNamespaces: # List of namespaces where CRDs live - - github-configuration - # - another-namespace # Add more namespaces as needed - # appCredentialsSecretNamespace: "" # Optional; defaults to release namespace if unset -``` +## Kustomize Deployment -The `WATCH_NAMESPACE` environment variable is derived by joining the list with commas (via the `chart.watchNamespace` helper). +Kustomize is used for local development and serves as the source for CRD generation: -The chart creates RBAC in the appropriate namespaces: - -| Resource | Namespace | Template | -|----------|-----------|----------| -| Controller Deployment | Release namespace | `deployment.yaml` | -| Manager Role + RoleBinding | Each namespace in `watchedNamespaces` | `manager-rbac.yaml` | -| App Credentials Role + RoleBinding | `appCredentialsSecretNamespace` (or release namespace) | `app-credentials-rbac.yaml` | -| Leader Election Role + RoleBinding | Release namespace | `leader-election-rbac.yaml` | -| Webhook `namespaceSelector` | Matches all namespaces in `watchedNamespaces` | `validating-webhook-configuration.yaml` | - -#### Multi-Namespace RBAC - -The `manager-rbac.yaml` template iterates over the `watchedNamespaces` list and creates a dedicated Role + RoleBinding pair in each namespace. This ensures the controller has permissions in every namespace it watches. - -#### Multi-Namespace Webhook Support +```bash +make install # Install CRDs into current cluster +make deploy # Deploy full operator via kustomize +make run # Run operator locally (CRDs must be installed) +``` -The webhook `namespaceSelector` uses `matchExpressions` with the `In` operator (via the `chart.webhookNamespaceSelector` helper in `_helpers.tpl`), which correctly handles multiple namespaces: +### Namespace Model -```yaml -# Single namespace -watchedNamespaces: - - github-configuration -# → matchExpressions: [{key: kubernetes.io/metadata.name, operator: In, values: ["github-configuration"]}] -# → WATCH_NAMESPACE=github-configuration +Kustomize collapses everything into a single namespace (`git-hubby-system`) via the global namespace transformer in `config/default/kustomization.yaml`. Patches align `WATCH_NAMESPACE` and webhook `namespaceSelector` with this namespace. -# Multiple namespaces -watchedNamespaces: - - ns-a - - ns-b -# → matchExpressions: [{key: kubernetes.io/metadata.name, operator: In, values: ["ns-a", "ns-b"]}] -# → WATCH_NAMESPACE=ns-a,ns-b -``` +## Helm Deployment (Production) +The Helm chart in [Interhyp/git-hubby-helm](https://github.com/Interhyp/git-hubby-helm) supports multi-namespace deployment: -### Namespace Configuration Summary +- **Controller namespace**: Release namespace +- **Watch namespaces**: Configurable list via `controllerManager.watchedNamespaces` +- **Credentials namespace**: Configurable via `controllerManager.appCredentialsSecretNamespace` -| Setting | Kustomize (`make deploy`) | Helm (production) | -|---------|---------------------------|-------------------| -| Controller namespace | `git-hubby-system` (from kustomization.yaml) | Release namespace | -| Watch namespace (`WATCH_NAMESPACE`) | `git-hubby-system` (patched) | Joined from `watchedNamespaces` list (default: `["github-configuration"]`) | -| Credentials namespace (`APP_CREDENTIALS_SECRET_NAMESPACE`) | `git-hubby-system` (hardcoded) | `appCredentialsSecretNamespace` value (default: release namespace) | -| Webhook namespace selector | `git-hubby-system` (patched) | Derived from `watchedNamespaces` | +The chart creates per-namespace RBAC (Role + RoleBinding) for each watched namespace. -## Why Kustomize is Required +## Why Kustomize is Still Required -Even if you only deploy via Helm in production, kustomize serves several purposes: +Even though the Helm chart is maintained separately: -1. **Helm chart generation**: `make helm` pipes kustomize output through helmify. Without kustomize, there is no Helm chart. -2. **Local development**: `make deploy` uses kustomize directly for quick iteration. -3. **`make run`**: Runs the controller locally against a cluster with CRDs installed via `make install` (which uses kustomize). -4. **CI/CD**: The e2e test pipeline uses kustomize-based deployment on Kind clusters. -5. **RBAC generation**: Kubebuilder RBAC markers generate kustomize manifests, which are the source of truth for the Helm chart's RBAC templates. +1. **CRD generation**: `make manifests` generates CRDs from kubebuilder markers into `config/crd/bases/`, which are then synced to the Helm chart. +2. **RBAC generation**: `config/rbac/role.yaml` serves as a reference when updating Helm RBAC templates. +3. **Local development**: `make deploy` and `make run` use kustomize directly. +4. **CI/CD**: E2E tests use kustomize-based deployment on Kind clusters. ## Adding a New Namespace-Sensitive Resource @@ -178,7 +73,4 @@ When adding manifests that need different namespaces in kustomize vs Helm: 1. **Set the production namespace** in the base manifest (e.g., `config/rbac/`). 2. **Add a kustomize patch** in `config/default/` if the kustomize flow needs a different namespace. -3. **Add a post-helmify step** in `hack/post-helmify/transformations.go` if helmify doesn't template the namespace correctly. -4. **Restore production defaults** in post-helmify if kustomize patches leak values into `values.yaml`. -5. **Run `make helm`** and verify `chart/values.yaml` has the correct production defaults. -6. **Add tests** in `hack/post-helmify/transformations_test.go` for the new transformation. +3. **Update the Helm chart** in `git-hubby-helm` with proper namespace templating. diff --git a/hack/post-helmify/main.go b/hack/post-helmify/main.go deleted file mode 100644 index befdfd9..0000000 --- a/hack/post-helmify/main.go +++ /dev/null @@ -1,100 +0,0 @@ -// post-helmify patches the Helm chart generated by helmify to apply -// customizations that helmify cannot produce natively. -// -// It performs the following transformations: -// - Replaces helmify-generated templates with pre-built versions from hack/post-helmify/templates/ -// - Patches deployment.yaml with custom Helm helpers for env vars and labels -// - Patches values.yaml to introduce watchedNamespaces list and remove kustomize artifacts -// - Adds namespace templating to RBAC and webhook resources -// -// Usage: -// -// go run ./hack/post-helmify [chart-path] -// -// If chart-path is not specified, defaults to "./chart". -package main - -import ( - "fmt" - "os" -) - -func main() { - chartPath := "./chart" - if len(os.Args) > 1 { - chartPath = os.Args[1] - } - - cfg := Config{ - ChartPath: chartPath, - TemplateSrcDir: "hack/post-helmify/templates", - } - - if err := run(cfg); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "ERROR: %v\n", err) - os.Exit(1) - } -} - -func run(cfg Config) error { - steps := []struct { - name string - fn func(Config) error - }{ - {"copy envs configmap template", copyEnvsConfigmapTemplate}, - {"patch deployment podLabels", patchDeploymentPodLabels}, - {"patch deployment strategy", patchDeploymentStrategy}, - {"patch deployment preStop hook", patchDeploymentPreStop}, - {"patch deployment topologySpreadConstraints", patchDeploymentTopologySpread}, - {"patch serviceaccount secrets", patchServiceAccountSecrets}, - {"replace configMapRef name", replaceConfigMapRefName}, - {"replace WATCH_NAMESPACE value", replaceWatchNamespaceValue}, - {"copy manager-rbac template", copyManagerRBACTemplate}, - {"patch app-credentials RBAC namespace", patchAppCredentialsRBAC}, - {"template webhook namespaceSelector", templateWebhookNamespaceSelector}, - {"template APP_CREDENTIALS_SECRET_NAMESPACE", templateAppCredentialsEnv}, - {"patch values.yaml namespaces", patchValuesNamespaces}, - {"patch serviceaccount labels", patchServiceAccountLabels}, - {"patch values.yaml defaults", patchValuesDefaults}, - {"copy PDB template", copyPDBTemplate}, - {"add podDisruptionBudget to values.yaml", addPDBValues}, - {"add topologySpreadConstraints to values.yaml", addTopologySpreadValues}, - {"copy serving-cert template", copyServingCertTemplate}, - {"add servingCert to values.yaml", addServingCertValues}, - } - - for _, step := range steps { - if err := step.fn(cfg); err != nil { - return fmt.Errorf("%s: %w", step.name, err) - } - fmt.Printf("✓ %s\n", step.name) - } - - fmt.Println("post-helmify completed") - return nil -} - -// Config holds paths used by the post-helmify transformations. -type Config struct { - ChartPath string - TemplateSrcDir string -} - -func (c Config) deployment() string { return c.ChartPath + "/templates/deployment.yaml" } -func (c Config) serviceAccount() string { return c.ChartPath + "/templates/serviceaccount.yaml" } -func (c Config) managerRBAC() string { return c.ChartPath + "/templates/manager-rbac.yaml" } -func (c Config) appCredsRBAC() string { return c.ChartPath + "/templates/app-credentials-rbac.yaml" } -func (c Config) webhook() string { - return c.ChartPath + "/templates/validating-webhook-configuration.yaml" -} -func (c Config) values() string { return c.ChartPath + "/values.yaml" } -func (c Config) pdb() string { return c.ChartPath + "/templates/poddisruptionbudget.yaml" } -func (c Config) pdbSrc() string { return c.TemplateSrcDir + "/poddisruptionbudget.yaml" } -func (c Config) servingCertSrc() string { return c.TemplateSrcDir + "/serving-cert.yaml" } -func (c Config) managerRBACSrc() string { return c.TemplateSrcDir + "/manager-rbac.yaml" } -func (c Config) envsConfigmapSrc() string { - return c.TemplateSrcDir + "/envs.configmap.yaml" -} -func (c Config) envsConfigmap() string { - return c.ChartPath + "/templates/envs.configmap.yaml" -} diff --git a/hack/post-helmify/templates/envs.configmap.yaml b/hack/post-helmify/templates/envs.configmap.yaml deleted file mode 100644 index 3ac3af4..0000000 --- a/hack/post-helmify/templates/envs.configmap.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "chart.fullname" . }}-envs - labels: - {{- include "chart.labels" . | nindent 4 }} -{{ if .Values.envs }} -data: - {{ range $key, $value := .Values.envs }} - {{ $key }}: {{ $value | quote }} - {{ end }} -{{ else }} -data: {} -{{ end }} diff --git a/hack/post-helmify/templates/manager-rbac.yaml b/hack/post-helmify/templates/manager-rbac.yaml deleted file mode 100644 index 916f0ec..0000000 --- a/hack/post-helmify/templates/manager-rbac.yaml +++ /dev/null @@ -1,88 +0,0 @@ -{{/* -Manager Role and RoleBinding for each watched namespace. -A Role + RoleBinding pair is created in each namespace listed in -controllerManager.watchedNamespaces so the controller has RBAC -permissions wherever it watches for CRDs. -*/}} -{{- range .Values.controllerManager.watchedNamespaces }} ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - namespace: {{ . | quote }} - name: {{ include "chart.fullname" $ }}-manager-role - labels: - {{- include "chart.labels" $ | nindent 4 }} -rules: -- apiGroups: - - "" - resources: - - secrets - verbs: - - get - - list - - watch -- apiGroups: - - github.interhyp.de - resources: - - autolinkspresets - - codesecurityconfigurations - - rulesetpresets - - webhookignorepresets - - webhookpresets - verbs: - - get - - list - - watch -- apiGroups: - - github.interhyp.de - resources: - - organizations - - repositories - - teams - verbs: - - create - - delete - - get - - list - - patch - - update - - watch -- apiGroups: - - github.interhyp.de - resources: - - organizations/finalizers - - repositories/finalizers - - teams/finalizers - verbs: - - update -- apiGroups: - - github.interhyp.de - resources: - - organizations/status - - repositories/status - - teams/status - verbs: - - get - - patch - - update ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - namespace: {{ . | quote }} - name: {{ include "chart.fullname" $ }}-manager-rolebinding - labels: - {{- include "chart.labels" $ | nindent 4 }} -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: {{ include "chart.fullname" $ }}-manager-role -subjects: -- kind: ServiceAccount - name: {{ include "chart.serviceAccountName" $ }} - namespace: {{ $.Release.Namespace | quote }} -{{- end }} - - - diff --git a/hack/post-helmify/templates/poddisruptionbudget.yaml b/hack/post-helmify/templates/poddisruptionbudget.yaml deleted file mode 100644 index 73c8d84..0000000 --- a/hack/post-helmify/templates/poddisruptionbudget.yaml +++ /dev/null @@ -1,23 +0,0 @@ -{{- with .Values.controllerManager.podDisruptionBudget }} -{{- if .enabled }} -apiVersion: policy/v1 -kind: PodDisruptionBudget -metadata: - name: {{ include "chart.fullname" $ }}-controller-manager - labels: - control-plane: controller-manager - {{- include "chart.labels" $ | nindent 4 }} -spec: - {{- with .minAvailable }} - minAvailable: {{ . }} - {{- end }} - {{- with .maxUnavailable }} - maxUnavailable: {{ . }} - {{- end }} - selector: - matchLabels: - control-plane: controller-manager - {{- include "chart.selectorLabels" $ | nindent 6 }} -{{- end }} -{{- end }} - diff --git a/hack/post-helmify/templates/serving-cert.yaml b/hack/post-helmify/templates/serving-cert.yaml deleted file mode 100644 index 0ce34d3..0000000 --- a/hack/post-helmify/templates/serving-cert.yaml +++ /dev/null @@ -1,33 +0,0 @@ -apiVersion: cert-manager.io/v1 -kind: Certificate -metadata: - name: {{ include "chart.fullname" . }}-serving-cert - labels: - {{- include "chart.labels" . | nindent 4 }} -spec: - dnsNames: - - '{{ include "chart.fullname" . }}-webhook-service.{{ .Release.Namespace }}.svc' - - '{{ include "chart.fullname" . }}-webhook-service.{{ .Release.Namespace }}.svc.{{ - .Values.kubernetesClusterDomain }}' - secretName: webhook-server-certificate - {{- with .Values.servingCert.duration }} - duration: {{ . }} - {{- end }} - issuerRef: - {{- toYaml .Values.servingCert.issuerRef | nindent 4 }} - {{- with .Values.servingCert.privateKey }} - privateKey: - {{- toYaml . | nindent 4 }} - {{- end }} - {{- with .Values.servingCert.renewBefore }} - renewBefore: {{ . }} - {{- end }} - {{- with .Values.servingCert.subject }} - subject: - {{- toYaml . | nindent 4 }} - {{- end }} - {{- with .Values.servingCert.usages }} - usages: - {{- toYaml . | nindent 2 }} - {{- end }} - diff --git a/hack/post-helmify/transformations.go b/hack/post-helmify/transformations.go deleted file mode 100644 index 3877049..0000000 --- a/hack/post-helmify/transformations.go +++ /dev/null @@ -1,515 +0,0 @@ -package main - -import ( - "os" - "regexp" - "strings" -) - -// --- File helpers --- - -// readFile reads a file and returns its content. Returns empty string and nil error if file doesn't exist. -func readFile(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - if os.IsNotExist(err) { - return "", nil - } - return "", err - } - return string(data), nil -} - -// writeFile writes content to a file, creating it if necessary. -func writeFile(path, content string) error { - return os.WriteFile(path, []byte(content), 0644) -} - -// copyFile copies src to dst. Returns nil if src doesn't exist. -func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - if os.IsNotExist(err) { - return nil - } - return err - } - return os.WriteFile(dst, data, 0644) -} - -// --- Deployment patches --- - -// patchDeploymentPreStop adds a preStop lifecycle hook to the manager container. -// This gives kube-proxy time to remove the pod from Service endpoints before it -// stops serving webhook traffic, preventing "context deadline exceeded" errors -// during rolling updates. -func patchDeploymentPreStop(cfg Config) error { - content, err := readFile(cfg.deployment()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, "preStop") { - return nil // already patched - } - preStop := ` lifecycle: - preStop: - exec: - command: ["sleep", "5"]` - content = insertBeforeLine(content, "volumeMounts:", preStop) - return writeFile(cfg.deployment(), content) -} - -// patchDeploymentStrategy replaces helmify's explicit per-field strategy block with a -// conditional toYaml block that works regardless of strategy type or missing keys. -func patchDeploymentStrategy(cfg Config) error { - content, err := readFile(cfg.deployment()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, "with .Values.controllerManager.strategy") { - return nil // already patched - } - // Match the helmify-generated strategy block (multi-line with rollingUpdate sub-fields). - // Matches " strategy:\n" followed by lines indented with 4+ spaces, up to the " selector:" line. - re := regexp.MustCompile(`(?m)^ strategy:\n( .*\n)+`) - replacement := ` {{- with .Values.controllerManager.strategy }} - strategy: {{- toYaml . | nindent 4 }} - {{- end }} -` - content = re.ReplaceAllString(content, replacement) - return writeFile(cfg.deployment(), content) -} - -// patchDeploymentPodLabels adds podLabels template block to deployment pod template. -func patchDeploymentPodLabels(cfg Config) error { - content, err := readFile(cfg.deployment()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, ".Values.controllerManager.podLabels") { - return nil // already patched - } - insertion := ` {{- with .Values.controllerManager.podLabels }} - {{ toYaml . | nindent 8 }} - {{- end }}` - content = insertAfterLine(content, " control-plane: controller-manager", insertion) - return writeFile(cfg.deployment(), content) -} - -// replaceConfigMapRefName replaces hardcoded configmap name with Helm template. -func replaceConfigMapRefName(cfg Config) error { - content, err := readFile(cfg.deployment()) - if err != nil || content == "" { - return err - } - if !strings.Contains(content, "name: controller-manager-envs") { - return nil - } - content = strings.Replace(content, "name: controller-manager-envs", `name: {{ include "chart.fullname" . }}-envs`, 1) - return writeFile(cfg.deployment(), content) -} - -// replaceWatchNamespaceValue replaces the WATCH_NAMESPACE env value with chart.watchNamespace helper. -func replaceWatchNamespaceValue(cfg Config) error { - content, err := readFile(cfg.deployment()) - if err != nil || content == "" { - return err - } - if !strings.Contains(content, "WATCH_NAMESPACE") { - return nil - } - if strings.Contains(content, `chart.watchNamespace`) { - return nil // already patched - } - content = replaceEnvValue(content, "WATCH_NAMESPACE", `{{ include "chart.watchNamespace" . | quote }}`) - return writeFile(cfg.deployment(), content) -} - -// templateAppCredentialsEnv templates the APP_CREDENTIALS_SECRET_NAMESPACE env var value. -func templateAppCredentialsEnv(cfg Config) error { - content, err := readFile(cfg.deployment()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, "controllerManager.appCredentialsSecretNamespace") { - return nil // already patched - } - if !strings.Contains(content, "APP_CREDENTIALS_SECRET_NAMESPACE") { - return nil - } - templateValue := `{{ .Values.controllerManager.appCredentialsSecretNamespace | default .Release.Namespace | quote }}` - // Handle helmify's multi-line value pattern: replaces value line + optional continuation line - content = replaceEnvValueMultiLine(content, "APP_CREDENTIALS_SECRET_NAMESPACE", templateValue) - return writeFile(cfg.deployment(), content) -} - -// --- ServiceAccount patches --- - -// patchServiceAccountSecrets adds secrets block to serviceaccount template. -func patchServiceAccountSecrets(cfg Config) error { - content, err := readFile(cfg.serviceAccount()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, "secrets") { - return nil - } - insertion := `{{- with .Values.serviceAccount.secrets }} -secrets: - {{- toYaml . | nindent 2 }} -{{- end }}` - content = insertBeforeLine(content, "automountServiceAccountToken:", insertion) - return writeFile(cfg.serviceAccount(), content) -} - -// patchServiceAccountLabels adds custom labels support to serviceaccount template. -func patchServiceAccountLabels(cfg Config) error { - content, err := readFile(cfg.serviceAccount()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, "serviceAccount.labels") { - return nil - } - insertion := ` {{- with .Values.serviceAccount.labels }} - {{- toYaml . | nindent 4 }} - {{- end }}` - content = insertAfterLine(content, `{{- include "chart.labels" . | nindent 4 }}`, insertion) - return writeFile(cfg.serviceAccount(), content) -} - -// --- RBAC patches --- - -// copyManagerRBACTemplate copies the multi-namespace manager RBAC template. -// Only requires the source to exist; the destination is created if missing. -func copyManagerRBACTemplate(cfg Config) error { - if _, err := os.Stat(cfg.managerRBACSrc()); os.IsNotExist(err) { - return nil - } - return copyFile(cfg.managerRBACSrc(), cfg.managerRBAC()) -} - -// patchAppCredentialsRBAC adds namespace template to app-credentials Role and RoleBinding. -func patchAppCredentialsRBAC(cfg Config) error { - content, err := readFile(cfg.appCredsRBAC()) - if err != nil || content == "" { - return err - } - nsLine := ` namespace: {{ .Values.controllerManager.appCredentialsSecretNamespace | default .Release.Namespace }}` - // Remove existing templated namespace lines - content = removeLineContaining(content, "appCredentialsSecretNamespace") - // Insert namespace after ALL metadata: lines - content = insertAfterAllLines(content, "metadata:", nsLine) - return writeFile(cfg.appCredsRBAC(), content) -} - -// --- Webhook patches --- - -// templateWebhookNamespaceSelector replaces hardcoded namespaceSelector with the helper. -func templateWebhookNamespaceSelector(cfg Config) error { - content, err := readFile(cfg.webhook()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, "chart.webhookNamespaceSelector") { - return nil // already patched - } - // Replace namespaceSelector block (3 lines: namespaceSelector + matchLabels + key/value) - re := regexp.MustCompile(`(?m) namespaceSelector:\n\s+matchLabels:\n\s+kubernetes\.io/metadata\.name:.*`) - replacement := ` {{- include "chart.webhookNamespaceSelector" . | nindent 2 }}` - content = re.ReplaceAllString(content, replacement) - return writeFile(cfg.webhook(), content) -} - -// --- PodDisruptionBudget patches --- - -// copyPDBTemplate copies the values-driven PDB template from hack/post-helmify/templates/. -// Only requires the source to exist; the destination is created if missing. -func copyEnvsConfigmapTemplate(cfg Config) error { - if _, err := os.Stat(cfg.envsConfigmapSrc()); os.IsNotExist(err) { - return nil - } - return copyFile(cfg.envsConfigmapSrc(), cfg.envsConfigmap()) -} - -func copyPDBTemplate(cfg Config) error { - if _, err := os.Stat(cfg.pdbSrc()); os.IsNotExist(err) { - return nil - } - return copyFile(cfg.pdbSrc(), cfg.pdb()) -} - -// addPDBValues adds podDisruptionBudget defaults to values.yaml if not present. -func addPDBValues(cfg Config) error { - content, err := readFile(cfg.values()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, "podDisruptionBudget") { - return nil - } - pdbBlock := " podDisruptionBudget:\n enabled: true\n minAvailable: 1\n # maxUnavailable: 1" - content = insertAfterLine(content, "replicas:", pdbBlock) - return writeFile(cfg.values(), content) -} - -// addTopologySpreadValues adds default topologySpreadConstraints to values.yaml if not present. -// This ensures replicas are distributed across nodes to prevent PDB violations during node drains. -func addTopologySpreadValues(cfg Config) error { - content, err := readFile(cfg.values()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, "topologyKey: kubernetes.io/hostname") { - return nil - } - // Replace empty topologySpreadConstraints with the actual constraint - old := " topologySpreadConstraints: []" - replacement := " topologySpreadConstraints:\n" + - " - maxSkew: 1\n" + - " topologyKey: kubernetes.io/hostname\n" + - " whenUnsatisfiable: ScheduleAnyway" - if strings.Contains(content, old) { - content = strings.Replace(content, old, replacement, 1) - } else if !strings.Contains(content, "topologySpreadConstraints") { - content = insertAfterLine(content, "tolerations:", replacement) - } - return writeFile(cfg.values(), content) -} - -// patchDeploymentTopologySpread replaces helmify's raw toYaml topologySpreadConstraints -// with a template that renders all user-provided fields via toYaml (using Helm's "omit" -// to exclude labelSelector), then injects a default labelSelector using chart.selectorLabels -// only when the user has not explicitly provided one. -func patchDeploymentTopologySpread(cfg Config) error { - content, err := readFile(cfg.deployment()) - if err != nil || content == "" { - return err - } - if !strings.Contains(content, "toYaml .Values.controllerManager.topologySpreadConstraints") { - return nil // already patched or no raw toYaml block to replace - } - // Match helmify-generated pattern: " topologySpreadConstraints: {{- toYaml ... }}" - //nolint:lll - re := regexp.MustCompile( - `(?m)^\s+topologySpreadConstraints:.*toYaml .Values\.controllerManager\.topologySpreadConstraints[^\n]*\n(\s+\| nindent 8 \}\}\n)?`, - ) - replacement := " {{- with .Values.controllerManager.topologySpreadConstraints }}\n" + - " topologySpreadConstraints:\n" + - " {{- range . }}\n" + - " - {{- toYaml (omit . \"labelSelector\") | nindent 10 }}\n" + - " {{- if .labelSelector }}\n" + - " labelSelector: {{- toYaml .labelSelector | nindent 12 }}\n" + - " {{- else }}\n" + - " labelSelector:\n" + - " matchLabels:\n" + - " control-plane: controller-manager\n" + - " {{- include \"chart.selectorLabels\" $ | nindent 14 }}\n" + - " {{- end }}\n" + - " {{- end }}\n" + - " {{- end }}\n" - content = re.ReplaceAllString(content, replacement) - return writeFile(cfg.deployment(), content) -} - -// --- Values.yaml patches --- - -// patchValuesNamespaces transforms values.yaml namespace configuration: -// - Removes watchNamespace and appCredentialsSecretNamespace from env section -// - Removes empty env: key -// - Adds controllerManager.watchedNamespaces list -func patchValuesNamespaces(cfg Config) error { - content, err := readFile(cfg.values()) - if err != nil || content == "" { - return err - } - // Remove watchNamespace line - content = removeLineContaining(content, "watchNamespace") - // Remove appCredentialsSecretNamespace line - content = removeLineContaining(content, "appCredentialsSecretNamespace") - // Remove empty env: lines (with only whitespace after colon) - re := regexp.MustCompile(`(?m)^\s*env:\s*$\n`) - content = re.ReplaceAllString(content, "") - // Add watchedNamespaces list if not present - if !strings.Contains(content, "watchedNamespaces") { - content = insertAfterLine(content, "controllerManager:", " watchedNamespaces:\n - github-configuration") - } - return writeFile(cfg.values(), content) -} - -// patchValuesDefaults adds default values for podLabels, secrets, and labels. -func patchValuesDefaults(cfg Config) error { - content, err := readFile(cfg.values()) - if err != nil || content == "" { - return err - } - if !strings.Contains(content, "podLabels") { - content = insertAfterLine(content, "controllerManager:", " podLabels: {}") - } - if !strings.Contains(content, "secrets") { - content = insertAfterLine(content, "serviceAccount:", " secrets: []") - } - // Check if labels: exists under serviceAccount context - if !hasKeyUnderSection(content, "serviceAccount:", "labels:") { - content = insertAfterLine(content, "serviceAccount:", " labels: {}") - } - return writeFile(cfg.values(), content) -} - -// copyServingCertTemplate copies the values-driven serving-cert template. -// Only requires the source to exist; the destination is created if missing. -func copyServingCertTemplate(cfg Config) error { - dst := cfg.ChartPath + "/templates/serving-cert.yaml" - if _, err := os.Stat(cfg.servingCertSrc()); os.IsNotExist(err) { - return nil - } - return copyFile(cfg.servingCertSrc(), dst) -} - -// addServingCertValues adds servingCert defaults to values.yaml if not present. -func addServingCertValues(cfg Config) error { - content, err := readFile(cfg.values()) - if err != nil || content == "" { - return err - } - if strings.Contains(content, "servingCert") { - return nil - } - content += `servingCert: - # duration: 2160h0m0s - issuerRef: - kind: Issuer - name: selfsigned-issuer - # privateKey: - # algorithm: RSA - # size: 4096 - # renewBefore: 360h0m0s - # subject: - # organizations: - # - My Organization - # usages: - # - server auth - # - client auth -` - return writeFile(cfg.values(), content) -} - -// --- String manipulation helpers --- - -// insertAfterLine inserts text after the FIRST line containing marker. -func insertAfterLine(content, marker, insertion string) string { - lines := strings.Split(content, "\n") - for i, line := range lines { - if strings.Contains(line, marker) { - result := make([]string, 0, len(lines)+1) - result = append(result, lines[:i+1]...) - result = append(result, insertion) - result = append(result, lines[i+1:]...) - return strings.Join(result, "\n") - } - } - return content -} - -// insertAfterAllLines inserts text after EVERY line containing marker. -func insertAfterAllLines(content, marker, insertion string) string { - lines := strings.Split(content, "\n") - result := make([]string, 0, len(lines)*2) - for _, line := range lines { - result = append(result, line) - if strings.Contains(line, marker) { - result = append(result, insertion) - } - } - return strings.Join(result, "\n") -} - -// insertBeforeLine inserts text before the FIRST line containing marker. -func insertBeforeLine(content, marker, insertion string) string { - lines := strings.Split(content, "\n") - for i, line := range lines { - if strings.Contains(line, marker) { - result := make([]string, 0, len(lines)+1) - result = append(result, lines[:i]...) - result = append(result, insertion) - result = append(result, lines[i:]...) - return strings.Join(result, "\n") - } - } - return content -} - -// removeLineContaining removes all lines containing the given substring. -func removeLineContaining(content, substr string) string { - lines := strings.Split(content, "\n") - result := make([]string, 0, len(lines)) - for _, line := range lines { - if !strings.Contains(line, substr) { - result = append(result, line) - } - } - return strings.Join(result, "\n") -} - -// replaceEnvValue replaces the value line following a `- name: ENV_NAME` line. -func replaceEnvValue(content, envName, newValue string) string { - lines := strings.Split(content, "\n") - for i, line := range lines { - if strings.Contains(line, "name: "+envName) && i+1 < len(lines) { - // Find the indentation of the value line - nextLine := lines[i+1] - indent := len(nextLine) - len(strings.TrimLeft(nextLine, " ")) - lines[i+1] = strings.Repeat(" ", indent) + "value: " + newValue - break - } - } - return strings.Join(lines, "\n") -} - -// replaceEnvValueMultiLine replaces the value line(s) following a `- name: ENV_NAME` line. -// Handles helmify's multi-line value pattern where the expression wraps to the next line -// (e.g., "value: {{ quote .Values.foo\n }}"). -func replaceEnvValueMultiLine(content, envName, newValue string) string { - lines := strings.Split(content, "\n") - result := make([]string, 0, len(lines)) - for i := 0; i < len(lines); i++ { - if strings.Contains(lines[i], "name: "+envName) && i+1 < len(lines) { - result = append(result, lines[i]) - // Determine indentation from the value line - nextLine := lines[i+1] - indent := len(nextLine) - len(strings.TrimLeft(nextLine, " ")) - result = append(result, strings.Repeat(" ", indent)+"value: "+newValue) - i++ // skip the original value line - // Skip continuation lines (lines that are just closing braces like " }}") - for i+1 < len(lines) { - candidate := strings.TrimSpace(lines[i+1]) - if candidate == "}}" || candidate == "| default .Chart.AppVersion }}" { - i++ - } else { - break - } - } - } else { - result = append(result, lines[i]) - } - } - return strings.Join(result, "\n") -} - -// hasKeyUnderSection checks if a key exists within a few lines after a section marker. -func hasKeyUnderSection(content, section, key string) bool { - lines := strings.Split(content, "\n") - for i, line := range lines { - if strings.Contains(line, section) { - // Check next 5 lines for the key - end := min(i+5, len(lines)) - for _, l := range lines[i+1 : end] { - if strings.Contains(l, key) { - return true - } - } - return false - } - } - return false -} diff --git a/hack/post-helmify/transformations_test.go b/hack/post-helmify/transformations_test.go deleted file mode 100644 index 81f00e9..0000000 --- a/hack/post-helmify/transformations_test.go +++ /dev/null @@ -1,908 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -// --- Helper test utilities --- - -func setupTestChart(t *testing.T) (Config, string) { - t.Helper() - dir := t.TempDir() - chartDir := filepath.Join(dir, "chart") - templatesDir := filepath.Join(chartDir, "templates") - srcDir := filepath.Join(dir, "config", "tmp") - os.MkdirAll(templatesDir, 0755) - os.MkdirAll(srcDir, 0755) - return Config{ChartPath: chartDir, TemplateSrcDir: srcDir}, dir -} - -func writeTestFile(t *testing.T, path, content string) { - t.Helper() - os.MkdirAll(filepath.Dir(path), 0755) - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } -} - -func readTestFile(t *testing.T, path string) string { - t.Helper() - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - return string(data) -} - -// --- String helper tests --- - -func TestInsertAfterLine(t *testing.T) { - input := "line1\nline2\nline3" - result := insertAfterLine(input, "line2", "inserted") - expected := "line1\nline2\ninserted\nline3" - if result != expected { - t.Errorf("got:\n%s\nwant:\n%s", result, expected) - } -} - -func TestInsertAfterLine_NotFound(t *testing.T) { - input := "line1\nline2\nline3" - result := insertAfterLine(input, "nothere", "inserted") - if result != input { - t.Errorf("expected no change, got:\n%s", result) - } -} - -func TestInsertBeforeLine(t *testing.T) { - input := "line1\nline2\nline3" - result := insertBeforeLine(input, "line2", "inserted") - expected := "line1\ninserted\nline2\nline3" - if result != expected { - t.Errorf("got:\n%s\nwant:\n%s", result, expected) - } -} - -func TestRemoveLineContaining(t *testing.T) { - input := "keep1\nremove this\nkeep2\nalso remove this\nkeep3" - result := removeLineContaining(input, "remove") - expected := "keep1\nkeep2\nkeep3" - if result != expected { - t.Errorf("got:\n%s\nwant:\n%s", result, expected) - } -} - -func TestReplaceEnvValue(t *testing.T) { - input := ` - name: MY_VAR - value: "old-value" - - name: OTHER` - result := replaceEnvValue(input, "MY_VAR", `{{ .new }}`) - if !strings.Contains(result, `value: {{ .new }}`) { - t.Errorf("replacement failed, got:\n%s", result) - } - if strings.Contains(result, "old-value") { - t.Error("old value still present") - } -} - -func TestHasKeyUnderSection(t *testing.T) { - input := "serviceAccount:\n labels: {}\n name: foo" - if !hasKeyUnderSection(input, "serviceAccount:", "labels:") { - t.Error("expected to find labels under serviceAccount") - } - if hasKeyUnderSection(input, "serviceAccount:", "missing:") { - t.Error("should not find missing key") - } -} - -// --- Deployment patch tests --- - -func TestPatchDeploymentPreStop(t *testing.T) { - cfg, _ := setupTestChart(t) - content := ` securityContext: - allowPrivilegeEscalation: false - volumeMounts: - - mountPath: /tmp/certs - name: webhook-certs` - writeTestFile(t, cfg.deployment(), content) - - if err := patchDeploymentPreStop(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if !strings.Contains(result, "preStop") { - t.Error("preStop hook not inserted") - } - if !strings.Contains(result, `["sleep", "5"]`) { - t.Error("sleep command not present") - } - // volumeMounts should still be present after the hook - if !strings.Contains(result, "volumeMounts:") { - t.Error("volumeMounts removed") - } -} - -func TestPatchDeploymentPreStop_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := ` lifecycle: - preStop: - exec: - command: ["sleep", "5"] - volumeMounts: - - mountPath: /tmp/certs` - writeTestFile(t, cfg.deployment(), content) - - if err := patchDeploymentPreStop(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if result != content { - t.Error("expected no change on already-patched content") - } -} - -func TestPatchDeploymentPodLabels(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `spec: - template: - metadata: - labels: - control-plane: controller-manager - app.kubernetes.io/name: git-hubby - spec: - containers: []` - writeTestFile(t, cfg.deployment(), content) - - if err := patchDeploymentPodLabels(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if !strings.Contains(result, ".Values.controllerManager.podLabels") { - t.Error("podLabels not inserted") - } - // Should not be in the first line - lines := strings.Split(result, "\n") - for i, l := range lines { - if strings.Contains(l, "control-plane: controller-manager") { - if i+1 >= len(lines) || !strings.Contains(lines[i+1], "podLabels") { - t.Error("podLabels not inserted after control-plane label") - } - break - } - } -} - -func TestPatchDeploymentPodLabels_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := ` control-plane: controller-manager - {{- with .Values.controllerManager.podLabels }} - {{ toYaml . | nindent 8 }} - {{- end }}` - writeTestFile(t, cfg.deployment(), content) - - if err := patchDeploymentPodLabels(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if result != content { - t.Error("expected no change on already-patched content") - } -} - -func TestPatchDeploymentStrategy(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `spec: - replicas: 2 - strategy: - rollingUpdate: - maxSurge: 1 - maxUnavailable: 0 - type: RollingUpdate - selector: - matchLabels: - control-plane: controller-manager` - writeTestFile(t, cfg.deployment(), content) - - if err := patchDeploymentStrategy(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if !strings.Contains(result, "with .Values.controllerManager.strategy") { - t.Error("strategy not replaced with values-driven block") - } - if !strings.Contains(result, "toYaml . | nindent 4") { - t.Error("toYaml helper not present") - } - if strings.Contains(result, "rollingUpdate:") { - t.Error("hardcoded rollingUpdate still present") - } - if strings.Contains(result, "maxSurge:") { - t.Error("hardcoded maxSurge still present") - } - // selector: line should still be present - if !strings.Contains(result, "selector:") { - t.Error("selector line was removed") - } -} - -func TestPatchDeploymentStrategy_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `spec: - replicas: 2 - {{- with .Values.controllerManager.strategy }} - strategy: {{- toYaml . | nindent 4 }} - {{- end }} - selector: - matchLabels: - control-plane: controller-manager` - writeTestFile(t, cfg.deployment(), content) - - if err := patchDeploymentStrategy(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if result != content { - t.Error("expected no change on already-patched content") - } -} - -func TestPatchDeploymentStrategy_MissingFile(t *testing.T) { - cfg, _ := setupTestChart(t) - // deployment file does not exist — should be a no-op - if err := patchDeploymentStrategy(cfg); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestReplaceConfigMapRefName(t *testing.T) { - cfg, _ := setupTestChart(t) - content := ` envFrom: - - configMapRef: - name: controller-manager-envs` - writeTestFile(t, cfg.deployment(), content) - - if err := replaceConfigMapRefName(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if !strings.Contains(result, `{{ include "chart.fullname" . }}-envs`) { - t.Error("configMapRef name not replaced") - } -} - -func TestReplaceWatchNamespaceValue(t *testing.T) { - cfg, _ := setupTestChart(t) - content := ` - name: WATCH_NAMESPACE - value: "git-hubby-system"` - writeTestFile(t, cfg.deployment(), content) - - if err := replaceWatchNamespaceValue(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if !strings.Contains(result, "chart.watchNamespace") { - t.Error("WATCH_NAMESPACE not replaced with helper") - } -} - -func TestReplaceWatchNamespaceValue_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := ` - name: WATCH_NAMESPACE - value: {{ include "chart.watchNamespace" . | quote }}` - writeTestFile(t, cfg.deployment(), content) - - if err := replaceWatchNamespaceValue(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if result != content { - t.Error("expected no change") - } -} - -func TestTemplateAppCredentialsEnv(t *testing.T) { - cfg, _ := setupTestChart(t) - content := ` - name: APP_CREDENTIALS_SECRET_NAMESPACE - value: "git-hubby-system"` - writeTestFile(t, cfg.deployment(), content) - - if err := templateAppCredentialsEnv(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if !strings.Contains(result, "appCredentialsSecretNamespace") { - t.Error("APP_CREDENTIALS_SECRET_NAMESPACE not templated") - } - if strings.Contains(result, `"git-hubby-system"`) { - t.Error("hardcoded value still present") - } -} - -// --- ServiceAccount tests --- - -func TestPatchServiceAccountSecrets(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `apiVersion: v1 -kind: ServiceAccount -metadata: - name: test -automountServiceAccountToken: true` - writeTestFile(t, cfg.serviceAccount(), content) - - if err := patchServiceAccountSecrets(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.serviceAccount()) - if !strings.Contains(result, "serviceAccount.secrets") { - t.Error("secrets not added") - } -} - -func TestPatchServiceAccountLabels(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `metadata: - labels: - {{- include "chart.labels" . | nindent 4 }} - name: test` - writeTestFile(t, cfg.serviceAccount(), content) - - if err := patchServiceAccountLabels(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.serviceAccount()) - if !strings.Contains(result, "serviceAccount.labels") { - t.Error("labels not added") - } -} - -// --- RBAC tests --- - -func TestCopyManagerRBACTemplate(t *testing.T) { - cfg, _ := setupTestChart(t) - srcContent := "# multi-namespace template" - dstContent := "# helmify generated" - writeTestFile(t, cfg.managerRBACSrc(), srcContent) - writeTestFile(t, cfg.managerRBAC(), dstContent) - - if err := copyManagerRBACTemplate(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.managerRBAC()) - if result != srcContent { - t.Error("manager-rbac not copied from source") - } -} - -func TestPatchAppCredentialsRBAC(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: test-role ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: test-rolebinding` - writeTestFile(t, cfg.appCredsRBAC(), content) - - if err := patchAppCredentialsRBAC(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.appCredsRBAC()) - if count := strings.Count(result, "appCredentialsSecretNamespace"); count != 2 { - t.Errorf("expected 2 namespace insertions, got %d", count) - } -} - -// --- Webhook tests --- - -func TestTemplateWebhookNamespaceSelector(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `webhooks: -- name: test - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: git-hubby-system - rules: []` - writeTestFile(t, cfg.webhook(), content) - - if err := templateWebhookNamespaceSelector(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.webhook()) - if !strings.Contains(result, "chart.webhookNamespaceSelector") { - t.Error("namespaceSelector not replaced with helper") - } - if strings.Contains(result, "matchLabels") { - t.Error("matchLabels still present") - } -} - -func TestTemplateWebhookNamespaceSelector_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := ` {{- include "chart.webhookNamespaceSelector" . | nindent 2 }}` - writeTestFile(t, cfg.webhook(), content) - - if err := templateWebhookNamespaceSelector(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.webhook()) - if result != content { - t.Error("expected no change") - } -} - -// --- PDB tests --- - -func TestCopyPDBTemplate(t *testing.T) { - cfg, _ := setupTestChart(t) - srcContent := `{{- with .Values.controllerManager.podDisruptionBudget }} -{{- if .enabled }} -apiVersion: policy/v1 -kind: PodDisruptionBudget -{{- end }} -{{- end }} -` - dstContent := "# helmify generated" - writeTestFile(t, cfg.pdbSrc(), srcContent) - writeTestFile(t, cfg.pdb(), dstContent) - - if err := copyPDBTemplate(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.pdb()) - if result != srcContent { - t.Error("PDB not copied from source") - } -} - -func TestCopyPDBTemplate_MissingSrc(t *testing.T) { - cfg, _ := setupTestChart(t) - writeTestFile(t, cfg.pdb(), "# helmify generated") - // src does not exist — should be a no-op - if err := copyPDBTemplate(cfg); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestCopyPDBTemplate_MissingDst(t *testing.T) { - cfg, _ := setupTestChart(t) - srcContent := "# source template" - writeTestFile(t, cfg.pdbSrc(), srcContent) - // dst does not exist — should be created from source - if err := copyPDBTemplate(cfg); err != nil { - t.Fatalf("unexpected error: %v", err) - } - - result := readTestFile(t, cfg.pdb()) - if result != srcContent { - t.Errorf("expected destination to be created with source content, got:\n%s", result) - } -} - -func TestAddPDBValues(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `controllerManager: - replicas: 2 - strategy: - type: RollingUpdate` - writeTestFile(t, cfg.values(), content) - - if err := addPDBValues(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.values()) - if !strings.Contains(result, "podDisruptionBudget:") { - t.Error("podDisruptionBudget not added") - } - if !strings.Contains(result, "enabled: true") { - t.Error("enabled default not set") - } - if !strings.Contains(result, "minAvailable: 1") { - t.Error("minAvailable default not set") - } -} - -func TestAddPDBValues_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `controllerManager: - replicas: 2 - podDisruptionBudget: - enabled: true - minAvailable: 1` - writeTestFile(t, cfg.values(), content) - - if err := addPDBValues(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.values()) - if strings.Count(result, "podDisruptionBudget") != 1 { - t.Error("podDisruptionBudget duplicated") - } -} - -func TestAddTopologySpreadValues(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `controllerManager: - tolerations: [] - topologySpreadConstraints: []` - writeTestFile(t, cfg.values(), content) - - if err := addTopologySpreadValues(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.values()) - if !strings.Contains(result, "topologyKey: kubernetes.io/hostname") { - t.Error("topologySpreadConstraints not added") - } - if !strings.Contains(result, "maxSkew: 1") { - t.Error("maxSkew not set") - } - if !strings.Contains(result, "ScheduleAnyway") { - t.Error("whenUnsatisfiable not set") - } - if strings.Contains(result, "topologySpreadConstraints: []") { - t.Error("empty placeholder still present") - } -} - -func TestAddTopologySpreadValues_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `controllerManager: - topologySpreadConstraints: - - maxSkew: 1 - topologyKey: kubernetes.io/hostname - whenUnsatisfiable: ScheduleAnyway` - writeTestFile(t, cfg.values(), content) - - if err := addTopologySpreadValues(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.values()) - if result != content { - t.Error("expected no change on already-configured content") - } -} - -func TestPatchDeploymentTopologySpread(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `spec: - template: - spec: - tolerations: [] - topologySpreadConstraints: {{- toYaml .Values.controllerManager.topologySpreadConstraints - | nindent 8 }} - volumes: []` - writeTestFile(t, cfg.deployment(), content) - - if err := patchDeploymentTopologySpread(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - // Should use omit to preserve all fields - if !strings.Contains(result, `omit . "labelSelector"`) { - t.Error("omit not used for full field preservation") - } - // Should inject default labelSelector with chart.selectorLabels - if !strings.Contains(result, "chart.selectorLabels") { - t.Error("chart.selectorLabels not injected") - } - // Should support custom labelSelector pass-through - if !strings.Contains(result, "if .labelSelector") { - t.Error("custom labelSelector support not present") - } - // Should use with block so empty constraints produce no output - if !strings.Contains(result, "with .Values.controllerManager.topologySpreadConstraints") { - t.Error("with block not present") - } - // volumes: should still be present after the block - if !strings.Contains(result, "volumes:") { - t.Error("volumes line was removed") - } -} - -func TestPatchDeploymentTopologySpread_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := ` {{- with .Values.controllerManager.topologySpreadConstraints }} - topologySpreadConstraints: - {{- range . }} - - {{- toYaml (omit . "labelSelector") | nindent 10 }} - {{- if .labelSelector }} - labelSelector: {{- toYaml .labelSelector | nindent 12 }} - {{- else }} - labelSelector: - matchLabels: - control-plane: controller-manager - {{- include "chart.selectorLabels" $ | nindent 14 }} - {{- end }} - {{- end }} - {{- end }}` - writeTestFile(t, cfg.deployment(), content) - - if err := patchDeploymentTopologySpread(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if result != content { - t.Errorf("expected no change on already-patched content, got:\n%s", result) - } -} - -func TestPatchDeploymentTopologySpread_MissingFile(t *testing.T) { - cfg, _ := setupTestChart(t) - if err := patchDeploymentTopologySpread(cfg); err != nil { - t.Fatalf("unexpected error: %v", err) - } -} - -func TestPatchDeploymentTopologySpread_SelectorLabelsElsewhere(t *testing.T) { - cfg, _ := setupTestChart(t) - // Simulates a real deployment where chart.selectorLabels already appears - // in selector.matchLabels — the patch must still replace the raw toYaml block. - content := `spec: - selector: - matchLabels: - {{- include "chart.selectorLabels" . | nindent 6 }} - template: - spec: - topologySpreadConstraints: {{- toYaml .Values.controllerManager.topologySpreadConstraints - | nindent 8 }} - volumes: []` - writeTestFile(t, cfg.deployment(), content) - - if err := patchDeploymentTopologySpread(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.deployment()) - if !strings.Contains(result, `omit . "labelSelector"`) { - t.Error("patch was not applied despite raw toYaml block being present") - } - if strings.Contains(result, "toYaml .Values.controllerManager.topologySpreadConstraints") { - t.Error("raw toYaml block was not replaced") - } -} - -// --- Values.yaml tests --- - -func TestPatchValuesNamespaces(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `controllerManager: - manager: - env: - watchNamespace: git-hubby-system - appCredentialsSecretNamespace: git-hubby-system - image: - repository: test` - writeTestFile(t, cfg.values(), content) - - if err := patchValuesNamespaces(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.values()) - if strings.Contains(result, "watchNamespace") { - t.Error("watchNamespace not removed") - } - if strings.Contains(result, "appCredentialsSecretNamespace") { - t.Error("appCredentialsSecretNamespace not removed") - } - if !strings.Contains(result, "watchedNamespaces") { - t.Error("watchedNamespaces not added") - } - if !strings.Contains(result, "github-configuration") { - t.Error("default namespace not set") - } - // Empty env: line should be removed - for line := range strings.SplitSeq(result, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "env:" { - t.Error("empty env: key not removed") - } - } -} - -func TestPatchValuesNamespaces_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `controllerManager: - watchedNamespaces: - - github-configuration - manager: - image: - repository: test` - writeTestFile(t, cfg.values(), content) - - if err := patchValuesNamespaces(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.values()) - if strings.Count(result, "watchedNamespaces") != 1 { - t.Error("watchedNamespaces duplicated") - } -} - -func TestPatchValuesDefaults(t *testing.T) { - cfg, _ := setupTestChart(t) - content := `controllerManager: - manager: - image: test -serviceAccount: - name: test` - writeTestFile(t, cfg.values(), content) - - if err := patchValuesDefaults(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.values()) - if !strings.Contains(result, "podLabels") { - t.Error("podLabels not added") - } - if !strings.Contains(result, "secrets") { - t.Error("secrets not added") - } - if !strings.Contains(result, "labels:") { - t.Error("labels not added") - } -} - -func TestAddServingCertValues(t *testing.T) { - cfg, _ := setupTestChart(t) - content := "controllerManager:\n manager: {}\n" - writeTestFile(t, cfg.values(), content) - - if err := addServingCertValues(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.values()) - if !strings.Contains(result, "servingCert:") { - t.Error("servingCert not added") - } - if !strings.Contains(result, "selfsigned-issuer") { - t.Error("issuerRef not added") - } -} - -func TestAddServingCertValues_Idempotent(t *testing.T) { - cfg, _ := setupTestChart(t) - content := "controllerManager: {}\nservingCert:\n issuerRef:\n kind: Issuer\n" - writeTestFile(t, cfg.values(), content) - - if err := addServingCertValues(cfg); err != nil { - t.Fatal(err) - } - - result := readTestFile(t, cfg.values()) - if strings.Count(result, "servingCert") != 1 { - t.Error("servingCert duplicated") - } -} - -// --- Integration test --- - -func TestFullRun(t *testing.T) { - cfg, _ := setupTestChart(t) - - // Create minimal chart files - writeTestFile(t, cfg.deployment(), `spec: - template: - metadata: - labels: - control-plane: controller-manager - spec: - containers: - - name: manager - env: - - name: WATCH_NAMESPACE - value: "git-hubby-system" - - name: APP_CREDENTIALS_SECRET_NAMESPACE - value: "git-hubby-system" - envFrom: - - configMapRef: - name: controller-manager-envs`) - - writeTestFile(t, cfg.serviceAccount(), `apiVersion: v1 -kind: ServiceAccount -metadata: - labels: - {{- include "chart.labels" . | nindent 4 }} - name: test -automountServiceAccountToken: true`) - - writeTestFile(t, cfg.managerRBAC(), "# helmify generated") - writeTestFile(t, cfg.managerRBACSrc(), "# multi-namespace template") - - writeTestFile(t, cfg.appCredsRBAC(), `apiVersion: rbac.authorization.k8s.io/v1 -kind: Role -metadata: - name: test`) - - writeTestFile(t, cfg.webhook(), `webhooks: -- name: test - namespaceSelector: - matchLabels: - kubernetes.io/metadata.name: git-hubby-system - rules: []`) - - writeTestFile(t, cfg.values(), `controllerManager: - replicas: 2 - manager: - env: - watchNamespace: git-hubby-system - appCredentialsSecretNamespace: git-hubby-system - image: - repository: test -serviceAccount: - name: test`) - - writeTestFile(t, cfg.servingCertSrc(), "# serving cert template") - writeTestFile(t, cfg.ChartPath+"/templates/serving-cert.yaml", "# old") - - pdbSrcContent := `{{- with .Values.controllerManager.podDisruptionBudget }} -{{- if .enabled }} -apiVersion: policy/v1 -kind: PodDisruptionBudget -{{- end }} -{{- end }} -` - writeTestFile(t, cfg.pdbSrc(), pdbSrcContent) - writeTestFile(t, cfg.pdb(), "# helmify generated PDB") - - // Run the full orchestration — this tests step ordering, error propagation, and Config injection. - if err := run(cfg); err != nil { - t.Fatalf("run() failed: %v", err) - } - - // Verify key outcomes - deployment := readTestFile(t, cfg.deployment()) - if !strings.Contains(deployment, "chart.watchNamespace") { - t.Error("WATCH_NAMESPACE not templated") - } - if !strings.Contains(deployment, "appCredentialsSecretNamespace") { - t.Error("APP_CREDENTIALS not templated") - } - - values := readTestFile(t, cfg.values()) - if !strings.Contains(values, "watchedNamespaces") { - t.Error("watchedNamespaces not in values") - } - if strings.Contains(values, "watchNamespace") { - t.Error("watchNamespace still in values") - } - if !strings.Contains(values, "podDisruptionBudget") { - t.Error("podDisruptionBudget not in values") - } - - pdb := readTestFile(t, cfg.pdb()) - if pdb != pdbSrcContent { - t.Error("PDB not copied from source template") - } -}