From d46f4600a0411cd5282fafa752cf6b15fcaa36f8 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:06:48 -0300 Subject: [PATCH 01/16] docs: add spec for DecoRedirect redirectCode field and X-Redirect-By header Co-Authored-By: Claude Sonnet 4.6 --- ...ecoredirect-redirect-code-header-design.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md diff --git a/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md b/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md new file mode 100644 index 0000000..df12ff6 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md @@ -0,0 +1,131 @@ +# DecoRedirect: redirectCode field + X-Redirect-By header + +**Date:** 2026-05-28 +**Status:** Approved + +--- + +## Problem + +The `DecoRedirect` CRD currently hard-codes a 301 redirect via the `permanent-redirect` nginx annotation. There is no way to configure the redirect code per client, and no response header to identify that a redirect was served by Deco. + +--- + +## Goals + +1. Add a `redirectCode` field to `DecoRedirectSpec` accepting `301` or `307`, defaulting to `307`. +2. Validate the field at the CRD level (kubebuilder enum) so invalid values are rejected by the API server. +3. Add `X-Redirect-By: deco` to all redirect responses via nginx `add-headers`. + +--- + +## Design + +### 1. CRD — `redirectCode` field + +Add to `DecoRedirectSpec`: + +```go +// RedirectCode is the HTTP status code used for the redirect. Must be 301 or 307. +// Defaults to 307 if not set. +// +kubebuilder:validation:Enum=301;307 +// +kubebuilder:default=307 +// +optional +RedirectCode *int `json:"redirectCode,omitempty"` +``` + +- Use a pointer (`*int`) so the controller can distinguish "not set" (nil) from an explicit value. +- `+kubebuilder:default=307` makes the API server inject 307 on new CREATE requests when the field is omitted. +- Existing CRs (created before this change) will have `nil` at read time — the controller treats `nil` as 307. +- Validation is enforced by the Kubernetes API server via the generated CRD schema — no webhook needed. +- The HTTP API (`redirectRequest` / `redirectResponse`) exposes `redirectCode` as an optional `*int`; the handler passes it through to the CR spec. + +### 2. Controller — `reconcileIngress` change + +In `reconcileIngress`, set both annotations per Ingress: + +```go +code := 307 +if rd.Spec.RedirectCode != nil { + code = *rd.Spec.RedirectCode +} +ingress.Annotations = map[string]string{ + "nginx.ingress.kubernetes.io/permanent-redirect": rd.Spec.To, + "nginx.ingress.kubernetes.io/permanent-redirect-code": strconv.Itoa(code), +} +``` + +`permanent-redirect-code` is a per-Ingress annotation — each client's Ingress carries its own value, so 301 and 307 clients coexist without conflict. + +### 3. Header — Helm chart changes + +**New ConfigMap** (added as `extraObjects` in the chart): + +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: deco-custom-headers + namespace: deco-redirect-system +data: + X-Redirect-By: "deco" +``` + +**nginx values** (in `ingress-nginx.controller.config`): + +```yaml +add-headers: "deco-redirect-system/deco-custom-headers" +``` + +The nginx ingress controller reads the ConfigMap at startup and appends the headers to every response. Since this nginx instance is exclusively used for Deco redirects, a global header is correct behavior. + +--- + +## HTTP API changes + +`POST /redirects` request body gains an optional field: + +```json +{ + "from": "client.com", + "to": "https://www.client.com", + "redirectCode": 307 +} +``` + +`GET /redirects` and `GET /redirects/{domain}` response gains: + +```json +{ + "from": "client.com", + "to": "https://www.client.com", + "redirectCode": 307, + "certificateReady": true, + "createdAt": "2026-05-28T00:00:00Z" +} +``` + +Omitting `redirectCode` in POST defaults to `307` (kubebuilder default). +Invalid values (`302`, `308`, etc.) return `422 Unprocessable Entity` from the API server. + +--- + +## Files to change + +| File | Change | +|------|--------| +| `api/v1alpha1/decoredirect_types.go` | Add `RedirectCode int` field with kubebuilder markers | +| `internal/controller/decoredirect_controller.go` | Set `permanent-redirect-code` annotation in `reconcileIngress` | +| `internal/api/handlers.go` | Add `redirectCode` to `redirectRequest` and `redirectResponse`; pass through in `create`; render in `toResponse` | +| `internal/controller/decoredirect_controller_test.go` | Update/add tests for redirect code annotation | +| `internal/api/server_test.go` | Update/add tests for redirectCode in request/response | +| `chart/` | Add `deco-custom-headers` ConfigMap in `extraObjects`; add `add-headers` to nginx config values | +| `config/crd/bases/` | Regenerate via `make generate manifests` | + +--- + +## Out of scope + +- Supporting redirect codes other than 301 and 307. +- Per-client header values. +- Migrating existing CRs — existing CRs will have `nil` for `redirectCode`; the controller treats `nil` as 307. No explicit migration needed. From 6927142dee8b1a8a2b44fc573ed3ccb5b9ca60b4 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:10:43 -0300 Subject: [PATCH 02/16] docs: make decoHeader opt-in in spec (open-source chart concern) Co-Authored-By: Claude Sonnet 4.6 --- ...ecoredirect-redirect-code-header-design.md | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md b/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md index df12ff6..1571fab 100644 --- a/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md +++ b/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md @@ -57,25 +57,32 @@ ingress.Annotations = map[string]string{ `permanent-redirect-code` is a per-Ingress annotation — each client's Ingress carries its own value, so 301 and 307 clients coexist without conflict. -### 3. Header — Helm chart changes +### 3. Header — Helm chart changes (opt-in) -**New ConfigMap** (added as `extraObjects` in the chart): +This is an open-source chart. The header feature must be opt-in, parallel to `ingress-nginx.enabled`. A new value gates both the ConfigMap and the nginx config entry: ```yaml +# values.yaml +redirect: + decoHeader: + enabled: true # set to false to disable X-Redirect-By header +``` + +**New ConfigMap** (rendered conditionally in the chart): + +```yaml +{{- if .Values.redirect.decoHeader.enabled }} apiVersion: v1 kind: ConfigMap metadata: name: deco-custom-headers - namespace: deco-redirect-system + namespace: {{ .Values.redirect.namespace }} data: X-Redirect-By: "deco" +{{- end }} ``` -**nginx values** (in `ingress-nginx.controller.config`): - -```yaml -add-headers: "deco-redirect-system/deco-custom-headers" -``` +**nginx values** — the `add-headers` key is only injected when the feature is enabled. This is done by merging it into `ingress-nginx.controller.config` conditionally in the chart templates, not in `values.yaml`, so that consumers who set `redirect.decoHeader.enabled: false` are not affected. The nginx ingress controller reads the ConfigMap at startup and appends the headers to every response. Since this nginx instance is exclusively used for Deco redirects, a global header is correct behavior. @@ -119,7 +126,7 @@ Invalid values (`302`, `308`, etc.) return `422 Unprocessable Entity` from the A | `internal/api/handlers.go` | Add `redirectCode` to `redirectRequest` and `redirectResponse`; pass through in `create`; render in `toResponse` | | `internal/controller/decoredirect_controller_test.go` | Update/add tests for redirect code annotation | | `internal/api/server_test.go` | Update/add tests for redirectCode in request/response | -| `chart/` | Add `deco-custom-headers` ConfigMap in `extraObjects`; add `add-headers` to nginx config values | +| `chart/` | Add conditional ConfigMap template for `deco-custom-headers`; add `redirect.decoHeader.enabled` value; conditionally inject `add-headers` into nginx controller config | | `config/crd/bases/` | Regenerate via `make generate manifests` | --- From aa32ebb7f3669aa776b9ff89b8766e342dec3362 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:11:44 -0300 Subject: [PATCH 03/16] docs: make X-Redirect-By value configurable with default 'deco' Co-Authored-By: Claude Sonnet 4.6 --- .../2026-05-28-decoredirect-redirect-code-header-design.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md b/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md index 1571fab..5e4fc95 100644 --- a/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md +++ b/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md @@ -65,7 +65,8 @@ This is an open-source chart. The header feature must be opt-in, parallel to `in # values.yaml redirect: decoHeader: - enabled: true # set to false to disable X-Redirect-By header + enabled: true # set to false to disable X-Redirect-By header entirely + value: "deco" # value for the X-Redirect-By header ``` **New ConfigMap** (rendered conditionally in the chart): @@ -78,7 +79,7 @@ metadata: name: deco-custom-headers namespace: {{ .Values.redirect.namespace }} data: - X-Redirect-By: "deco" + X-Redirect-By: {{ .Values.redirect.decoHeader.value | quote }} {{- end }} ``` From 5147cac27f43c973a63d3a7b2e591bbf8b06e557 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:17:22 -0300 Subject: [PATCH 04/16] docs: add implementation plan for DecoRedirect redirectCode + X-Redirect-By header Co-Authored-By: Claude Sonnet 4.6 --- ...05-28-decoredirect-redirect-code-header.md | 697 ++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-decoredirect-redirect-code-header.md diff --git a/docs/superpowers/plans/2026-05-28-decoredirect-redirect-code-header.md b/docs/superpowers/plans/2026-05-28-decoredirect-redirect-code-header.md new file mode 100644 index 0000000..72ec9bd --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-decoredirect-redirect-code-header.md @@ -0,0 +1,697 @@ +# DecoRedirect: redirectCode + X-Redirect-By Header — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a configurable `redirectCode` field (301 or 307, default 307) to the `DecoRedirect` CRD, and add an opt-in `X-Redirect-By` response header to all redirects via the nginx ingress controller. + +**Architecture:** The `redirectCode` field is enforced at the CRD schema level via kubebuilder enum validation and rendered as `nginx.ingress.kubernetes.io/permanent-redirect-code` on each per-domain Ingress. The header is injected globally by the nginx ingress controller via a ConfigMap referenced by `add-headers`, gated by `redirect.decoHeader.enabled` in the Helm values. + +**Tech Stack:** Go 1.23, kubebuilder v4, controller-runtime, ingress-nginx, Helm 3, envtest (Ginkgo/Gomega for controller tests, stdlib `testing` for API tests). + +--- + +## File Map + +| File | Action | Purpose | +|------|--------|---------| +| `api/v1alpha1/decoredirect_types.go` | Modify | Add `RedirectCode *int` field with kubebuilder markers | +| `config/crd/bases/deco.sites_decoredict.yaml` | Auto-regenerated | CRD schema with enum + default | +| `chart/templates/customresourcedefinition-decoredict.deco.sites.yaml` | Auto-regenerated | Helm-bundled CRD | +| `internal/controller/decoredirect_controller.go` | Modify | Set `permanent-redirect-code` annotation in `reconcileIngress` | +| `internal/controller/decoredirect_controller_test.go` | Modify | New tests: redirectCode annotation + invalid value rejection | +| `internal/api/handlers.go` | Modify | Add `redirectCode` to request/response structs and wire up | +| `internal/api/server_test.go` | Modify | New tests: redirectCode in POST and GET | +| `chart/templates/configmap-redirect-custom-headers.yaml` | Create | Conditional ConfigMap for `X-Redirect-By` header | +| `chart/values.yaml` | Modify | Add `redirect.decoHeader` block; document `ingress-nginx.controller.config.add-headers` | + +--- + +## Task 1: Add `redirectCode` field to CRD types + +**Files:** +- Modify: `api/v1alpha1/decoredirect_types.go` + +- [ ] **Step 1: Write the failing controller test for invalid redirectCode** + +Add this `It` block inside the existing `Context("When reconciling a DecoRedirect", ...)` in `internal/controller/decoredirect_controller_test.go`, after the last `It` block: + +```go +It("should reject a DecoRedirect with an invalid redirectCode", func() { + invalidCode := 302 + err := k8sClient.Create(ctx, &decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid-code", Namespace: rdNS}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: "invalid-code.com", + To: "https://www.invalid-code.com", + RedirectCode: &invalidCode, + }, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("redirectCode")) +}) +``` + +- [ ] **Step 2: Run the test to confirm it fails to compile** (field doesn't exist yet) + +```bash +cd /Users/igoramf/projects/deco/operator +go test ./internal/controller/... 2>&1 | head -20 +``` + +Expected: compile error `unknown field RedirectCode in struct literal` + +- [ ] **Step 3: Add `RedirectCode` to `DecoRedirectSpec`** + +In `api/v1alpha1/decoredirect_types.go`, add the field after the `To` field: + +```go +// DecoRedirectSpec defines the desired state of DecoRedirect. +// +kubebuilder:validation:XValidation:rule="(self.to+'/').contains('.'+self.from+'/') || (self.to+'/').contains('//'+self.from+'/')",message="redirect target must be within the same domain as 'from' (e.g. from: client.com → to: https://www.client.com)" +type DecoRedirectSpec struct { + // From is the apex domain to redirect (e.g. "client.com"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$` + From string `json:"from"` + + // To is the full target URL within the same domain (e.g. "https://www.client.com"). + // +kubebuilder:validation:Required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=2048 + // +kubebuilder:validation:Pattern=`^https?://` + To string `json:"to"` + + // RedirectCode is the HTTP status code used for the redirect. Allowed values: 301, 307. + // Defaults to 307 (Temporary Redirect) if not set. + // +kubebuilder:validation:Enum=301;307 + // +kubebuilder:default=307 + // +optional + RedirectCode *int `json:"redirectCode,omitempty"` +} +``` + +- [ ] **Step 4: Regenerate DeepCopy, CRD manifests, and Helm chart** + +```bash +cd /Users/igoramf/projects/deco/operator +make generate +``` + +Expected: exits 0. This regenerates: +- `zz_generated.deepcopy.go` (adds `RedirectCode` pointer copy) +- `config/crd/bases/deco.sites_decoredict.yaml` (adds `redirectCode` with enum + default) +- `chart/templates/customresourcedefinition-decoredict.deco.sites.yaml` (same, Helm copy) + +- [ ] **Step 5: Run the controller tests** + +```bash +cd /Users/igoramf/projects/deco/operator +make test 2>&1 | tail -20 +``` + +Expected: all existing tests pass. The new test for `invalid-code` should **fail** because the CRD schema doesn't enforce enum on the fake client yet — envtest uses the real API server, so it **should** fail with an error containing `redirectCode`. If the test passes (correctly rejects the invalid value), great — move on. + +> Note: If envtest rejects the invalid value, the test passes. If it doesn't (unlikely), check that `make generate` correctly updated the CRD YAML with `enum: [301, 307]`. + +- [ ] **Step 6: Commit** + +```bash +git add api/v1alpha1/decoredirect_types.go \ + api/v1alpha1/zz_generated.deepcopy.go \ + config/crd/bases/ \ + chart/templates/customresourcedefinition-decoredict.deco.sites.yaml \ + internal/controller/decoredirect_controller_test.go +git commit -m "feat(crd): add redirectCode field (enum 301|307, default 307) to DecoRedirect" +``` + +--- + +## Task 2: Controller — set `permanent-redirect-code` annotation + +**Files:** +- Modify: `internal/controller/decoredirect_controller.go` +- Modify: `internal/controller/decoredirect_controller_test.go` + +- [ ] **Step 1: Write the failing test for redirectCode annotation** + +Add two `It` blocks to `decoredirect_controller_test.go` inside the existing `Context`, after the test added in Task 1: + +```go +It("should set permanent-redirect-code to 307 by default", func() { + _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + ing := &networkingv1.Ingress{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "redirect-client-com", Namespace: rdNS, + }, ing)).To(Succeed()) + Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("307")) +}) + +It("should use redirectCode 301 in the Ingress annotation when specified", func() { + code := 301 + rd301 := &decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: "test-redirect-301", Namespace: rdNS}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: "redirect301.com", + To: "https://www.redirect301.com", + RedirectCode: &code, + }, + } + Expect(k8sClient.Create(ctx, rd301)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, rd301) }) + + nn301 := types.NamespacedName{Name: "test-redirect-301", Namespace: rdNS} + _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn301}) + Expect(err).NotTo(HaveOccurred()) + + ing := &networkingv1.Ingress{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "redirect-redirect301-com", Namespace: rdNS, + }, ing)).To(Succeed()) + Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("301")) +}) +``` + +- [ ] **Step 2: Run the tests — expect the new ones to fail** + +```bash +cd /Users/igoramf/projects/deco/operator +go test ./internal/controller/... -v -run "TestControllers" 2>&1 | grep -E "FAIL|PASS|should set permanent|should use redirectCode" +``` + +Expected: new tests FAIL (annotation not set yet). + +- [ ] **Step 3: Update `reconcileIngress` in the controller** + +In `internal/controller/decoredirect_controller.go`, add `"strconv"` to the imports, then replace the annotation map in `reconcileIngress`: + +Full updated `reconcileIngress` function: + +```go +func (r *DecoRedirectReconciler) reconcileIngress(ctx context.Context, rd *decositesv1alpha1.DecoRedirect) error { + pathType := networkingv1.PathTypePrefix + ingress := &networkingv1.Ingress{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName(rd.Spec.From), + Namespace: rd.Namespace, + }, + } + if err := controllerutil.SetControllerReference(rd, ingress, r.Scheme); err != nil { + return err + } + + code := 307 + if rd.Spec.RedirectCode != nil { + code = *rd.Spec.RedirectCode + } + + _, err := controllerutil.CreateOrUpdate(ctx, r.Client, ingress, func() error { + ingress.Annotations = map[string]string{ + "nginx.ingress.kubernetes.io/permanent-redirect": rd.Spec.To, + "nginx.ingress.kubernetes.io/permanent-redirect-code": strconv.Itoa(code), + } + ingress.Spec = networkingv1.IngressSpec{ + IngressClassName: &r.IngressClass, + TLS: []networkingv1.IngressTLS{ + { + Hosts: []string{rd.Spec.From}, + SecretName: tlsSecretName(rd.Spec.From), + }, + }, + Rules: []networkingv1.IngressRule{ + { + Host: rd.Spec.From, + IngressRuleValue: networkingv1.IngressRuleValue{ + HTTP: &networkingv1.HTTPIngressRuleValue{ + Paths: []networkingv1.HTTPIngressPath{ + { + Path: "/", + PathType: &pathType, + Backend: networkingv1.IngressBackend{ + Service: &networkingv1.IngressServiceBackend{ + Name: dummyBackendName, + Port: networkingv1.ServiceBackendPort{Number: 80}, + }, + }, + }, + }, + }, + }, + }, + }, + } + return nil + }) + return err +} +``` + +Add `"strconv"` to the import block at the top of the file. + +- [ ] **Step 4: Run the tests — expect all to pass** + +```bash +cd /Users/igoramf/projects/deco/operator +go test ./internal/controller/... -v 2>&1 | tail -30 +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/controller/decoredirect_controller.go \ + internal/controller/decoredirect_controller_test.go +git commit -m "feat(controller): set permanent-redirect-code annotation from spec.redirectCode" +``` + +--- + +## Task 3: HTTP API — expose `redirectCode` in request and response + +**Files:** +- Modify: `internal/api/handlers.go` +- Modify: `internal/api/server_test.go` + +- [ ] **Step 1: Write failing tests for `redirectCode` in POST and GET** + +Add these test functions to `internal/api/server_test.go`: + +```go +func TestCreate_WithRedirectCode(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = decositesv1alpha1.AddToScheme(scheme) + fc := fake.NewClientBuilder().WithScheme(scheme).Build() + h := api.NewHandlers(fc, "deco-redirect-system") + srv := api.NewServer(":0", "user", "pass", h) + + code := 301 + body, _ := json.Marshal(map[string]interface{}{"from": "example.com", "to": "https://www.example.com", "redirectCode": code}) + req := httptest.NewRequest(http.MethodPost, "/redirects", bytes.NewReader(body)) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + + list := &decositesv1alpha1.DecoRedirectList{} + _ = fc.List(context.Background(), list) + if len(list.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(list.Items)) + } + if list.Items[0].Spec.RedirectCode == nil || *list.Items[0].Spec.RedirectCode != 301 { + t.Fatalf("expected redirectCode=301, got %v", list.Items[0].Spec.RedirectCode) + } +} + +func TestGet_IncludesRedirectCode(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = decositesv1alpha1.AddToScheme(scheme) + code := 301 + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: "example-com", Namespace: "deco-redirect-system"}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: "example.com", + To: "https://www.example.com", + RedirectCode: &code, + }, + }).Build() + h := api.NewHandlers(fc, "deco-redirect-system") + srv := api.NewServer(":0", "user", "pass", h) + + req := httptest.NewRequest(http.MethodGet, "/redirects/example.com", nil) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var item struct { + From string `json:"from"` + RedirectCode *int `json:"redirectCode"` + } + _ = json.NewDecoder(rec.Body).Decode(&item) + if item.RedirectCode == nil || *item.RedirectCode != 301 { + t.Fatalf("expected redirectCode=301 in response, got %v", item.RedirectCode) + } +} +``` + +- [ ] **Step 2: Run the new tests — expect them to fail** + +```bash +cd /Users/igoramf/projects/deco/operator +go test ./internal/api/... -v -run "TestCreate_WithRedirectCode|TestGet_IncludesRedirectCode" 2>&1 +``` + +Expected: FAIL — `RedirectCode` field unknown in request struct. + +- [ ] **Step 3: Update `handlers.go`** + +Replace the full content of `internal/api/handlers.go` with: + +```go +package api + +import ( + "encoding/json" + "net/http" + "regexp" + "strings" + + decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var domainRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) + +type Handlers struct { + client client.Client + defaultNamespace string +} + +func NewHandlers(c client.Client, defaultNamespace string) *Handlers { + if defaultNamespace == "" { + defaultNamespace = "deco-redirect-system" + } + return &Handlers{client: c, defaultNamespace: defaultNamespace} +} + +type redirectRequest struct { + From string `json:"from"` + To string `json:"to"` + Namespace string `json:"namespace,omitempty"` + RedirectCode *int `json:"redirectCode,omitempty"` +} + +type redirectResponse struct { + From string `json:"from"` + To string `json:"to"` + RedirectCode *int `json:"redirectCode,omitempty"` + CertificateReady bool `json:"certificateReady"` + Message string `json:"message,omitempty"` + CreatedAt string `json:"createdAt"` +} + +func toResponse(rd *decositesv1alpha1.DecoRedirect) redirectResponse { + resp := redirectResponse{ + From: rd.Spec.From, + To: rd.Spec.To, + RedirectCode: rd.Spec.RedirectCode, + CreatedAt: rd.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z"), + } + for _, c := range rd.Status.Conditions { + if c.Type == "CertificateReady" { + resp.CertificateReady = c.Status == "True" + if c.Status != "True" { + resp.Message = c.Message + } + break + } + } + return resp +} + +func (h *Handlers) create(w http.ResponseWriter, r *http.Request) { + var req redirectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + from := strings.ToLower(strings.TrimSpace(req.From)) + if !domainRe.MatchString(from) { + http.Error(w, "invalid domain in 'from'", http.StatusBadRequest) + return + } + to := strings.TrimSpace(req.To) + if to == "" { + http.Error(w, "'to' is required", http.StatusBadRequest) + return + } + if !strings.HasPrefix(to, "http://") && !strings.HasPrefix(to, "https://") { + to = "https://" + to + } + ns := h.nsOrDefault(req.Namespace) + + rd := &decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{ + Name: domainToName(from), + Namespace: ns, + }, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: from, + To: to, + RedirectCode: req.RedirectCode, + }, + } + if err := h.client.Create(r.Context(), rd); err != nil { + status := http.StatusInternalServerError + if apierrors.IsInvalid(err) { + status = http.StatusUnprocessableEntity + } else if apierrors.IsAlreadyExists(err) { + status = http.StatusConflict + } + http.Error(w, err.Error(), status) + return + } + w.WriteHeader(http.StatusCreated) +} + +func (h *Handlers) get(w http.ResponseWriter, r *http.Request) { + rawDomain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) + if !domainRe.MatchString(rawDomain) { + http.Error(w, "invalid domain", http.StatusBadRequest) + return + } + domain := domainToName(rawDomain) + ns := h.nsOrDefault(r.URL.Query().Get("namespace")) + + rd := &decositesv1alpha1.DecoRedirect{} + if err := h.client.Get(r.Context(), client.ObjectKey{Name: domain, Namespace: ns}, rd); err != nil { + status := http.StatusInternalServerError + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + http.Error(w, err.Error(), status) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(toResponse(rd)) +} + +func (h *Handlers) delete(w http.ResponseWriter, r *http.Request) { + rawDomain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) + if !domainRe.MatchString(rawDomain) { + http.Error(w, "invalid domain", http.StatusBadRequest) + return + } + domain := domainToName(rawDomain) + ns := h.nsOrDefault(r.URL.Query().Get("namespace")) + + rd := &decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: domain, Namespace: ns}, + } + if err := h.client.Delete(r.Context(), rd); err != nil { + status := http.StatusInternalServerError + if apierrors.IsNotFound(err) { + status = http.StatusNotFound + } + http.Error(w, err.Error(), status) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handlers) list(w http.ResponseWriter, r *http.Request) { + ns := h.nsOrDefault(r.URL.Query().Get("namespace")) + + list := &decositesv1alpha1.DecoRedirectList{} + if err := h.client.List(r.Context(), list, client.InNamespace(ns)); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + items := make([]redirectResponse, len(list.Items)) + for i := range list.Items { + items[i] = toResponse(&list.Items[i]) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(items) +} + +// domainToName converts a domain to a valid k8s resource name (dots → dashes). +func domainToName(d string) string { + return strings.ReplaceAll(d, ".", "-") +} + +func (h *Handlers) nsOrDefault(ns string) string { + if ns == "" { + return h.defaultNamespace + } + return ns +} +``` + +- [ ] **Step 4: Run all API tests** + +```bash +cd /Users/igoramf/projects/deco/operator +go test ./internal/api/... -v 2>&1 | tail -20 +``` + +Expected: all tests PASS. + +- [ ] **Step 5: Run the full test suite** + +```bash +cd /Users/igoramf/projects/deco/operator +make test 2>&1 | tail -10 +``` + +Expected: PASS, no failures. + +- [ ] **Step 6: Commit** + +```bash +git add internal/api/handlers.go internal/api/server_test.go +git commit -m "feat(api): add redirectCode to DecoRedirect request and response" +``` + +--- + +## Task 4: Helm chart — opt-in `X-Redirect-By` header + +**Files:** +- Create: `chart/templates/configmap-redirect-custom-headers.yaml` +- Modify: `chart/values.yaml` + +- [ ] **Step 1: Add `redirect.decoHeader` to `values.yaml` and wire up `ingress-nginx.controller.config`** + +**Change 1:** In `chart/values.yaml`, add the `decoHeader` block inside the `redirect:` section, after the `clusterIssuer:` block. The full `redirect:` block after the edit: + +```yaml +redirect: + namespace: "deco-redirect-system" + ingressClass: "" # set to enable DecoRedirect controller (e.g. "redirect-nginx") + clusterIssuer: + enabled: false # set true to create the Let's Encrypt ClusterIssuer + name: "" # ClusterIssuer name (e.g. "letsencrypt") + email: "" # required by Let's Encrypt ACME + staging: false # set true to use Let's Encrypt staging (avoids rate limits when testing) + solverAnnotations: {} # extra annotations on the HTTP-01 challenge Ingress + decoHeader: + enabled: false # set true to add X-Redirect-By response header to all redirects served by this nginx instance + value: "deco" # value for the X-Redirect-By header; override to use your own identifier +``` + +**Change 2:** In the `ingress-nginx:` section of `chart/values.yaml`, add `add-headers` to `controller.config`. The full updated `ingress-nginx:` block: + +```yaml +ingress-nginx: + enabled: false + namespaceOverride: "deco-redirect-system" + controller: + ingressClass: redirect-nginx + ingressClassResource: + name: redirect-nginx + controllerValue: "k8s.io/redirect-nginx" + service: + annotations: {} + config: + # Set to "/deco-custom-headers" when redirect.decoHeader.enabled=true. + # The ConfigMap is only created when redirect.decoHeader.enabled=true. + add-headers: "" +``` + +- [ ] **Step 2: Create the conditional ConfigMap template** + +Create file `chart/templates/configmap-redirect-custom-headers.yaml`: + +```yaml +{{- if .Values.redirect.decoHeader.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: deco-custom-headers + namespace: {{ .Values.redirect.namespace }} +data: + X-Redirect-By: {{ .Values.redirect.decoHeader.value | quote }} +{{- end }} +``` + +- [ ] **Step 3: Verify the template renders correctly when enabled** + +```bash +cd /Users/igoramf/projects/deco/operator +helm template test chart/ --set redirect.decoHeader.enabled=true --set redirect.decoHeader.value=deco 2>&1 | grep -A 8 "deco-custom-headers" +``` + +Expected output: +```yaml +# Source: deco-operator/templates/configmap-redirect-custom-headers.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: deco-custom-headers + namespace: deco-redirect-system +data: + X-Redirect-By: "deco" +``` + +- [ ] **Step 4: Verify the template renders nothing when disabled (default)** + +```bash +cd /Users/igoramf/projects/deco/operator +helm template test chart/ 2>&1 | grep -c "deco-custom-headers" +``` + +Expected output: `0` (ConfigMap not present in rendered output) + +- [ ] **Step 5: Lint the chart** + +```bash +cd /Users/igoramf/projects/deco/operator +helm lint chart/ +``` + +Expected: `1 chart(s) linted, 0 chart(s) failed` + +- [ ] **Step 6: Commit** + +```bash +git add chart/templates/configmap-redirect-custom-headers.yaml chart/values.yaml +git commit -m "feat(chart): add opt-in X-Redirect-By header via redirect.decoHeader" +``` + +--- + +## Final check + +- [ ] **Run full test suite one last time** + +```bash +cd /Users/igoramf/projects/deco/operator +make test 2>&1 | tail -10 +``` + +Expected: all tests pass, coverage written to `cover.out`. + +- [ ] **Verify the branch is clean** + +```bash +git log --oneline main..HEAD +``` + +Expected (4 commits on top of main): +``` + feat(chart): add opt-in X-Redirect-By header via redirect.decoHeader + feat(api): add redirectCode to DecoRedirect request and response + feat(controller): set permanent-redirect-code annotation from spec.redirectCode + feat(crd): add redirectCode field (enum 301|307, default 307) to DecoRedirect +``` From ef7f6ea9ad5c8db7fbcb22ea866c63ca8cac71eb Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:20:27 -0300 Subject: [PATCH 05/16] feat(crd): add redirectCode field (enum 301|307, default 307) to DecoRedirect Co-Authored-By: Claude Sonnet 4.6 --- api/v1alpha1/decoredirect_types.go | 7 +++++++ api/v1alpha1/zz_generated.deepcopy.go | 7 ++++++- ...omresourcedefinition-decoredict.deco.sites.yaml | 9 +++++++++ config/crd/bases/deco.sites_decoredict.yaml | 9 +++++++++ .../controller/decoredirect_controller_test.go | 14 ++++++++++++++ 5 files changed, 45 insertions(+), 1 deletion(-) diff --git a/api/v1alpha1/decoredirect_types.go b/api/v1alpha1/decoredirect_types.go index 2aff149..67eac01 100644 --- a/api/v1alpha1/decoredirect_types.go +++ b/api/v1alpha1/decoredirect_types.go @@ -22,6 +22,13 @@ type DecoRedirectSpec struct { // +kubebuilder:validation:MaxLength=2048 // +kubebuilder:validation:Pattern=`^https?://` To string `json:"to"` + + // RedirectCode is the HTTP status code used for the redirect. Allowed values: 301, 307. + // Defaults to 307 (Temporary Redirect) if not set. + // +kubebuilder:validation:Enum=301;307 + // +kubebuilder:default=307 + // +optional + RedirectCode *int `json:"redirectCode,omitempty"` } // DecoRedirectStatus defines the observed state of DecoRedirect. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 03a1280..aa9b034 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -163,7 +163,7 @@ func (in *DecoRedirect) DeepCopyInto(out *DecoRedirect) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -220,6 +220,11 @@ func (in *DecoRedirectList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *DecoRedirectSpec) DeepCopyInto(out *DecoRedirectSpec) { *out = *in + if in.RedirectCode != nil { + in, out := &in.RedirectCode, &out.RedirectCode + *out = new(int) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DecoRedirectSpec. diff --git a/chart/templates/customresourcedefinition-decoredict.deco.sites.yaml b/chart/templates/customresourcedefinition-decoredict.deco.sites.yaml index cf47501..e5bb60a 100644 --- a/chart/templates/customresourcedefinition-decoredict.deco.sites.yaml +++ b/chart/templates/customresourcedefinition-decoredict.deco.sites.yaml @@ -55,6 +55,15 @@ spec: minLength: 1 pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$ type: string + redirectCode: + default: 307 + description: |- + RedirectCode is the HTTP status code used for the redirect. Allowed values: 301, 307. + Defaults to 307 (Temporary Redirect) if not set. + enum: + - 301 + - 307 + type: integer to: description: To is the full target URL within the same domain (e.g. "https://www.client.com"). diff --git a/config/crd/bases/deco.sites_decoredict.yaml b/config/crd/bases/deco.sites_decoredict.yaml index af25b0e..069ede5 100644 --- a/config/crd/bases/deco.sites_decoredict.yaml +++ b/config/crd/bases/deco.sites_decoredict.yaml @@ -56,6 +56,15 @@ spec: minLength: 1 pattern: ^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$ type: string + redirectCode: + default: 307 + description: |- + RedirectCode is the HTTP status code used for the redirect. Allowed values: 301, 307. + Defaults to 307 (Temporary Redirect) if not set. + enum: + - 301 + - 307 + type: integer to: description: To is the full target URL within the same domain (e.g. "https://www.client.com"). diff --git a/internal/controller/decoredirect_controller_test.go b/internal/controller/decoredirect_controller_test.go index 7ce6c4f..052f559 100644 --- a/internal/controller/decoredirect_controller_test.go +++ b/internal/controller/decoredirect_controller_test.go @@ -117,6 +117,20 @@ var _ = Describe("DecoRedirect Controller", func() { Expect(err.Error()).To(ContainSubstring("redirect target must be within the same domain")) }) + It("should reject a DecoRedirect with an invalid redirectCode", func() { + invalidCode := 302 + err := k8sClient.Create(ctx, &decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: "invalid-code", Namespace: rdNS}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: "invalid-code.com", + To: "https://www.invalid-code.com", + RedirectCode: &invalidCode, + }, + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("redirectCode")) + }) + It("should not create duplicate Certificate on repeated reconcile", func() { _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn}) Expect(err).NotTo(HaveOccurred()) From 3e6ea4518602cc470b360d51c131e4166c5b05eb Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:24:17 -0300 Subject: [PATCH 06/16] feat(controller): set permanent-redirect-code annotation from spec.redirectCode Co-Authored-By: Claude Sonnet 4.6 --- .../controller/decoredirect_controller.go | 11 ++++-- .../decoredirect_controller_test.go | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index dfc8448..0238ca0 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "fmt" + "strconv" "strings" "time" @@ -108,10 +109,16 @@ func (r *DecoRedirectReconciler) reconcileIngress(ctx context.Context, rd *decos return err } - // nginx returns 301 via the permanent-redirect annotation before reaching any backend. + code := 307 + if rd.Spec.RedirectCode != nil { + code = *rd.Spec.RedirectCode + } + + // nginx returns the configured redirect code (default 307) via the permanent-redirect annotation before reaching any backend. _, err := controllerutil.CreateOrUpdate(ctx, r.Client, ingress, func() error { ingress.Annotations = map[string]string{ - "nginx.ingress.kubernetes.io/permanent-redirect": rd.Spec.To, + "nginx.ingress.kubernetes.io/permanent-redirect": rd.Spec.To, + "nginx.ingress.kubernetes.io/permanent-redirect-code": strconv.Itoa(code), } ingress.Spec = networkingv1.IngressSpec{ IngressClassName: &r.IngressClass, diff --git a/internal/controller/decoredirect_controller_test.go b/internal/controller/decoredirect_controller_test.go index 052f559..a73890a 100644 --- a/internal/controller/decoredirect_controller_test.go +++ b/internal/controller/decoredirect_controller_test.go @@ -148,5 +148,40 @@ var _ = Describe("DecoRedirect Controller", func() { } Expect(count).To(Equal(1)) }) + + It("should set permanent-redirect-code to 307 by default", func() { + _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn}) + Expect(err).NotTo(HaveOccurred()) + + ing := &networkingv1.Ingress{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "redirect-client-com", Namespace: rdNS, + }, ing)).To(Succeed()) + Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("307")) + }) + + It("should use redirectCode 301 in the Ingress annotation when specified", func() { + code := 301 + rd301 := &decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: "test-redirect-301", Namespace: rdNS}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: "redirect301.com", + To: "https://www.redirect301.com", + RedirectCode: &code, + }, + } + Expect(k8sClient.Create(ctx, rd301)).To(Succeed()) + DeferCleanup(func() { _ = k8sClient.Delete(ctx, rd301) }) + + nn301 := types.NamespacedName{Name: "test-redirect-301", Namespace: rdNS} + _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn301}) + Expect(err).NotTo(HaveOccurred()) + + ing := &networkingv1.Ingress{} + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "redirect-redirect301-com", Namespace: rdNS, + }, ing)).To(Succeed()) + Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("301")) + }) }) }) From 9f04204399fe799206f36c0ac1b24c5956b9bff7 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:27:27 -0300 Subject: [PATCH 07/16] feat(api): add redirectCode to DecoRedirect request and response Co-Authored-By: Claude Sonnet 4.6 --- internal/api/handlers.go | 20 +++++++----- internal/api/server_test.go | 61 +++++++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 3971417..112875d 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -27,14 +27,16 @@ func NewHandlers(c client.Client, defaultNamespace string) *Handlers { } type redirectRequest struct { - From string `json:"from"` - To string `json:"to"` - Namespace string `json:"namespace,omitempty"` + From string `json:"from"` + To string `json:"to"` + Namespace string `json:"namespace,omitempty"` + RedirectCode *int `json:"redirectCode,omitempty"` } type redirectResponse struct { From string `json:"from"` To string `json:"to"` + RedirectCode *int `json:"redirectCode,omitempty"` CertificateReady bool `json:"certificateReady"` Message string `json:"message,omitempty"` CreatedAt string `json:"createdAt"` @@ -42,9 +44,10 @@ type redirectResponse struct { func toResponse(rd *decositesv1alpha1.DecoRedirect) redirectResponse { resp := redirectResponse{ - From: rd.Spec.From, - To: rd.Spec.To, - CreatedAt: rd.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z"), + From: rd.Spec.From, + To: rd.Spec.To, + RedirectCode: rd.Spec.RedirectCode, + CreatedAt: rd.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z"), } for _, c := range rd.Status.Conditions { if c.Type == "CertificateReady" { @@ -85,8 +88,9 @@ func (h *Handlers) create(w http.ResponseWriter, r *http.Request) { Namespace: ns, }, Spec: decositesv1alpha1.DecoRedirectSpec{ - From: from, // original domain preserved for CEL validation - To: to, + From: from, // original domain preserved for CEL validation + To: to, + RedirectCode: req.RedirectCode, }, } if err := h.client.Create(r.Context(), rd); err != nil { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 7ad2dd3..129e302 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -173,3 +173,64 @@ func TestList_HappyPath(t *testing.T) { t.Fatalf("expected 1 item, got %d", len(items)) } } + +func TestCreate_WithRedirectCode(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = decositesv1alpha1.AddToScheme(scheme) + fc := fake.NewClientBuilder().WithScheme(scheme).Build() + h := api.NewHandlers(fc, "deco-redirect-system") + srv := api.NewServer(":0", "user", "pass", h) + + code := 301 + body, _ := json.Marshal(map[string]interface{}{"from": "example.com", "to": "https://www.example.com", "redirectCode": code}) + req := httptest.NewRequest(http.MethodPost, "/redirects", bytes.NewReader(body)) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) + } + + list := &decositesv1alpha1.DecoRedirectList{} + _ = fc.List(context.Background(), list) + if len(list.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(list.Items)) + } + if list.Items[0].Spec.RedirectCode == nil || *list.Items[0].Spec.RedirectCode != 301 { + t.Fatalf("expected redirectCode=301, got %v", list.Items[0].Spec.RedirectCode) + } +} + +func TestGet_IncludesRedirectCode(t *testing.T) { + scheme := runtime.NewScheme() + _ = clientgoscheme.AddToScheme(scheme) + _ = decositesv1alpha1.AddToScheme(scheme) + code := 301 + fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&decositesv1alpha1.DecoRedirect{ + ObjectMeta: metav1.ObjectMeta{Name: "example-com", Namespace: "deco-redirect-system"}, + Spec: decositesv1alpha1.DecoRedirectSpec{ + From: "example.com", + To: "https://www.example.com", + RedirectCode: &code, + }, + }).Build() + h := api.NewHandlers(fc, "deco-redirect-system") + srv := api.NewServer(":0", "user", "pass", h) + + req := httptest.NewRequest(http.MethodGet, "/redirects/example.com", nil) + req.SetBasicAuth("user", "pass") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rec.Code) + } + var item struct { + From string `json:"from"` + RedirectCode *int `json:"redirectCode"` + } + _ = json.NewDecoder(rec.Body).Decode(&item) + if item.RedirectCode == nil || *item.RedirectCode != 301 { + t.Fatalf("expected redirectCode=301 in response, got %v", item.RedirectCode) + } +} From e8c54859334feda1f0b0a8595048f9cf78981547 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:29:17 -0300 Subject: [PATCH 08/16] docs(api): document that redirectCode validation is delegated to CRD schema --- internal/api/handlers.go | 1 + 1 file changed, 1 insertion(+) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index 112875d..7864bd1 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -93,6 +93,7 @@ func (h *Handlers) create(w http.ResponseWriter, r *http.Request) { RedirectCode: req.RedirectCode, }, } + // redirectCode enum validation (301|307) is enforced by the CRD schema; invalid values return 422. if err := h.client.Create(r.Context(), rd); err != nil { status := http.StatusInternalServerError if apierrors.IsInvalid(err) { From 0b9121a9e127bdeeb994ccfeff14cc024a4ade97 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:30:33 -0300 Subject: [PATCH 09/16] feat(chart): add opt-in X-Redirect-By header via redirect.decoHeader Co-Authored-By: Claude Sonnet 4.6 --- chart/templates/configmap-redirect-custom-headers.yaml | 9 +++++++++ chart/values.yaml | 7 +++++++ 2 files changed, 16 insertions(+) create mode 100644 chart/templates/configmap-redirect-custom-headers.yaml diff --git a/chart/templates/configmap-redirect-custom-headers.yaml b/chart/templates/configmap-redirect-custom-headers.yaml new file mode 100644 index 0000000..c536c69 --- /dev/null +++ b/chart/templates/configmap-redirect-custom-headers.yaml @@ -0,0 +1,9 @@ +{{- if .Values.redirect.decoHeader.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: deco-custom-headers + namespace: {{ .Values.redirect.namespace }} +data: + X-Redirect-By: {{ .Values.redirect.decoHeader.value | quote }} +{{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 5cfad51..0b0af38 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -146,6 +146,9 @@ redirect: email: "" # required by Let's Encrypt ACME staging: false # set true to use Let's Encrypt staging (avoids rate limits when testing) solverAnnotations: {} # extra annotations on the HTTP-01 challenge Ingress (e.g. nginx.ingress.kubernetes.io/ssl-redirect: "false") + decoHeader: + enabled: false # set true to add X-Redirect-By response header to all redirects served by this nginx instance + value: "deco" # value for the X-Redirect-By header; override to use your own identifier # ingress-nginx subchart (opt-in) — set enabled: true to deploy nginx alongside the operator. # namespaceOverride isolates nginx from the operator namespace. @@ -160,6 +163,10 @@ ingress-nginx: controllerValue: "k8s.io/redirect-nginx" service: annotations: {} + config: + # Set to "/deco-custom-headers" when redirect.decoHeader.enabled=true. + # The ConfigMap is only created when redirect.decoHeader.enabled=true. + add-headers: "" # Name overrides nameOverride: "" From 5a346f7161173c6dac94aca702bf290abffc0bb7 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:33:52 -0300 Subject: [PATCH 10/16] feat(chart): warn when decoHeader enabled but add-headers not configured --- chart/templates/NOTES.txt | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 chart/templates/NOTES.txt diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt new file mode 100644 index 0000000..1d1a863 --- /dev/null +++ b/chart/templates/NOTES.txt @@ -0,0 +1,5 @@ +{{- if and .Values.redirect.decoHeader.enabled (eq (index .Values "ingress-nginx" "controller" "config" "add-headers") "") }} +WARNING: redirect.decoHeader.enabled is true but ingress-nginx.controller.config.add-headers +is not set. The X-Redirect-By header will not be added until you also set: + ingress-nginx.controller.config.add-headers: "{{ .Values.redirect.namespace }}/deco-custom-headers" +{{- end }} From 41eb072dd800c575e2f7a46b77e4636a38756a63 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 16:38:37 -0300 Subject: [PATCH 11/16] fix(chart): generate configmap-redirect-custom-headers via helm generator Prevents cleanTemplates() from deleting the hand-placed ConfigMap on every make generate run by adding an addRedirectCustomHeaders generator function. Co-Authored-By: Claude Sonnet 4.6 --- hack/helm-generator/main.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index c97b0d5..57448eb 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -105,6 +105,9 @@ func main() { if err := addClusterIssuer(templatesDir); err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not add ClusterIssuer: %v\n", err) } + if err := addRedirectCustomHeaders(templatesDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Could not add redirect custom headers ConfigMap: %v\n", err) + } if err := addRedirectControllerArgs(templatesDir); err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not add redirect controller args: %v\n", err) } @@ -399,6 +402,20 @@ spec: return os.WriteFile(filepath.Join(templatesDir, "clusterissuer-letsencrypt.yaml"), []byte(content), 0644) } +func addRedirectCustomHeaders(templatesDir string) error { + content := `{{- if .Values.redirect.decoHeader.enabled }} +apiVersion: v1 +kind: ConfigMap +metadata: + name: deco-custom-headers + namespace: {{ .Values.redirect.namespace }} +data: + X-Redirect-By: {{ .Values.redirect.decoHeader.value | quote }} +{{- end }} +` + return os.WriteFile(filepath.Join(templatesDir, "configmap-redirect-custom-headers.yaml"), []byte(content), 0644) +} + func addRedirectControllerArgs(templatesDir string) error { files, err := filepath.Glob(filepath.Join(templatesDir, "deployment-*.yaml")) if err != nil || len(files) == 0 { From 5112c3ca06b005ebcd64405f8759629f822a26a8 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 17:11:51 -0300 Subject: [PATCH 12/16] chore: remove superpowers plans and spec docs --- ...05-28-decoredirect-redirect-code-header.md | 697 ------------------ ...ecoredirect-redirect-code-header-design.md | 139 ---- 2 files changed, 836 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-28-decoredirect-redirect-code-header.md delete mode 100644 docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md diff --git a/docs/superpowers/plans/2026-05-28-decoredirect-redirect-code-header.md b/docs/superpowers/plans/2026-05-28-decoredirect-redirect-code-header.md deleted file mode 100644 index 72ec9bd..0000000 --- a/docs/superpowers/plans/2026-05-28-decoredirect-redirect-code-header.md +++ /dev/null @@ -1,697 +0,0 @@ -# DecoRedirect: redirectCode + X-Redirect-By Header — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a configurable `redirectCode` field (301 or 307, default 307) to the `DecoRedirect` CRD, and add an opt-in `X-Redirect-By` response header to all redirects via the nginx ingress controller. - -**Architecture:** The `redirectCode` field is enforced at the CRD schema level via kubebuilder enum validation and rendered as `nginx.ingress.kubernetes.io/permanent-redirect-code` on each per-domain Ingress. The header is injected globally by the nginx ingress controller via a ConfigMap referenced by `add-headers`, gated by `redirect.decoHeader.enabled` in the Helm values. - -**Tech Stack:** Go 1.23, kubebuilder v4, controller-runtime, ingress-nginx, Helm 3, envtest (Ginkgo/Gomega for controller tests, stdlib `testing` for API tests). - ---- - -## File Map - -| File | Action | Purpose | -|------|--------|---------| -| `api/v1alpha1/decoredirect_types.go` | Modify | Add `RedirectCode *int` field with kubebuilder markers | -| `config/crd/bases/deco.sites_decoredict.yaml` | Auto-regenerated | CRD schema with enum + default | -| `chart/templates/customresourcedefinition-decoredict.deco.sites.yaml` | Auto-regenerated | Helm-bundled CRD | -| `internal/controller/decoredirect_controller.go` | Modify | Set `permanent-redirect-code` annotation in `reconcileIngress` | -| `internal/controller/decoredirect_controller_test.go` | Modify | New tests: redirectCode annotation + invalid value rejection | -| `internal/api/handlers.go` | Modify | Add `redirectCode` to request/response structs and wire up | -| `internal/api/server_test.go` | Modify | New tests: redirectCode in POST and GET | -| `chart/templates/configmap-redirect-custom-headers.yaml` | Create | Conditional ConfigMap for `X-Redirect-By` header | -| `chart/values.yaml` | Modify | Add `redirect.decoHeader` block; document `ingress-nginx.controller.config.add-headers` | - ---- - -## Task 1: Add `redirectCode` field to CRD types - -**Files:** -- Modify: `api/v1alpha1/decoredirect_types.go` - -- [ ] **Step 1: Write the failing controller test for invalid redirectCode** - -Add this `It` block inside the existing `Context("When reconciling a DecoRedirect", ...)` in `internal/controller/decoredirect_controller_test.go`, after the last `It` block: - -```go -It("should reject a DecoRedirect with an invalid redirectCode", func() { - invalidCode := 302 - err := k8sClient.Create(ctx, &decositesv1alpha1.DecoRedirect{ - ObjectMeta: metav1.ObjectMeta{Name: "invalid-code", Namespace: rdNS}, - Spec: decositesv1alpha1.DecoRedirectSpec{ - From: "invalid-code.com", - To: "https://www.invalid-code.com", - RedirectCode: &invalidCode, - }, - }) - Expect(err).To(HaveOccurred()) - Expect(err.Error()).To(ContainSubstring("redirectCode")) -}) -``` - -- [ ] **Step 2: Run the test to confirm it fails to compile** (field doesn't exist yet) - -```bash -cd /Users/igoramf/projects/deco/operator -go test ./internal/controller/... 2>&1 | head -20 -``` - -Expected: compile error `unknown field RedirectCode in struct literal` - -- [ ] **Step 3: Add `RedirectCode` to `DecoRedirectSpec`** - -In `api/v1alpha1/decoredirect_types.go`, add the field after the `To` field: - -```go -// DecoRedirectSpec defines the desired state of DecoRedirect. -// +kubebuilder:validation:XValidation:rule="(self.to+'/').contains('.'+self.from+'/') || (self.to+'/').contains('//'+self.from+'/')",message="redirect target must be within the same domain as 'from' (e.g. from: client.com → to: https://www.client.com)" -type DecoRedirectSpec struct { - // From is the apex domain to redirect (e.g. "client.com"). - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=253 - // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$` - From string `json:"from"` - - // To is the full target URL within the same domain (e.g. "https://www.client.com"). - // +kubebuilder:validation:Required - // +kubebuilder:validation:MinLength=1 - // +kubebuilder:validation:MaxLength=2048 - // +kubebuilder:validation:Pattern=`^https?://` - To string `json:"to"` - - // RedirectCode is the HTTP status code used for the redirect. Allowed values: 301, 307. - // Defaults to 307 (Temporary Redirect) if not set. - // +kubebuilder:validation:Enum=301;307 - // +kubebuilder:default=307 - // +optional - RedirectCode *int `json:"redirectCode,omitempty"` -} -``` - -- [ ] **Step 4: Regenerate DeepCopy, CRD manifests, and Helm chart** - -```bash -cd /Users/igoramf/projects/deco/operator -make generate -``` - -Expected: exits 0. This regenerates: -- `zz_generated.deepcopy.go` (adds `RedirectCode` pointer copy) -- `config/crd/bases/deco.sites_decoredict.yaml` (adds `redirectCode` with enum + default) -- `chart/templates/customresourcedefinition-decoredict.deco.sites.yaml` (same, Helm copy) - -- [ ] **Step 5: Run the controller tests** - -```bash -cd /Users/igoramf/projects/deco/operator -make test 2>&1 | tail -20 -``` - -Expected: all existing tests pass. The new test for `invalid-code` should **fail** because the CRD schema doesn't enforce enum on the fake client yet — envtest uses the real API server, so it **should** fail with an error containing `redirectCode`. If the test passes (correctly rejects the invalid value), great — move on. - -> Note: If envtest rejects the invalid value, the test passes. If it doesn't (unlikely), check that `make generate` correctly updated the CRD YAML with `enum: [301, 307]`. - -- [ ] **Step 6: Commit** - -```bash -git add api/v1alpha1/decoredirect_types.go \ - api/v1alpha1/zz_generated.deepcopy.go \ - config/crd/bases/ \ - chart/templates/customresourcedefinition-decoredict.deco.sites.yaml \ - internal/controller/decoredirect_controller_test.go -git commit -m "feat(crd): add redirectCode field (enum 301|307, default 307) to DecoRedirect" -``` - ---- - -## Task 2: Controller — set `permanent-redirect-code` annotation - -**Files:** -- Modify: `internal/controller/decoredirect_controller.go` -- Modify: `internal/controller/decoredirect_controller_test.go` - -- [ ] **Step 1: Write the failing test for redirectCode annotation** - -Add two `It` blocks to `decoredirect_controller_test.go` inside the existing `Context`, after the test added in Task 1: - -```go -It("should set permanent-redirect-code to 307 by default", func() { - _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn}) - Expect(err).NotTo(HaveOccurred()) - - ing := &networkingv1.Ingress{} - Expect(k8sClient.Get(ctx, types.NamespacedName{ - Name: "redirect-client-com", Namespace: rdNS, - }, ing)).To(Succeed()) - Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("307")) -}) - -It("should use redirectCode 301 in the Ingress annotation when specified", func() { - code := 301 - rd301 := &decositesv1alpha1.DecoRedirect{ - ObjectMeta: metav1.ObjectMeta{Name: "test-redirect-301", Namespace: rdNS}, - Spec: decositesv1alpha1.DecoRedirectSpec{ - From: "redirect301.com", - To: "https://www.redirect301.com", - RedirectCode: &code, - }, - } - Expect(k8sClient.Create(ctx, rd301)).To(Succeed()) - DeferCleanup(func() { _ = k8sClient.Delete(ctx, rd301) }) - - nn301 := types.NamespacedName{Name: "test-redirect-301", Namespace: rdNS} - _, err := newReconciler().Reconcile(ctx, reconcile.Request{NamespacedName: nn301}) - Expect(err).NotTo(HaveOccurred()) - - ing := &networkingv1.Ingress{} - Expect(k8sClient.Get(ctx, types.NamespacedName{ - Name: "redirect-redirect301-com", Namespace: rdNS, - }, ing)).To(Succeed()) - Expect(ing.Annotations["nginx.ingress.kubernetes.io/permanent-redirect-code"]).To(Equal("301")) -}) -``` - -- [ ] **Step 2: Run the tests — expect the new ones to fail** - -```bash -cd /Users/igoramf/projects/deco/operator -go test ./internal/controller/... -v -run "TestControllers" 2>&1 | grep -E "FAIL|PASS|should set permanent|should use redirectCode" -``` - -Expected: new tests FAIL (annotation not set yet). - -- [ ] **Step 3: Update `reconcileIngress` in the controller** - -In `internal/controller/decoredirect_controller.go`, add `"strconv"` to the imports, then replace the annotation map in `reconcileIngress`: - -Full updated `reconcileIngress` function: - -```go -func (r *DecoRedirectReconciler) reconcileIngress(ctx context.Context, rd *decositesv1alpha1.DecoRedirect) error { - pathType := networkingv1.PathTypePrefix - ingress := &networkingv1.Ingress{ - ObjectMeta: metav1.ObjectMeta{ - Name: resourceName(rd.Spec.From), - Namespace: rd.Namespace, - }, - } - if err := controllerutil.SetControllerReference(rd, ingress, r.Scheme); err != nil { - return err - } - - code := 307 - if rd.Spec.RedirectCode != nil { - code = *rd.Spec.RedirectCode - } - - _, err := controllerutil.CreateOrUpdate(ctx, r.Client, ingress, func() error { - ingress.Annotations = map[string]string{ - "nginx.ingress.kubernetes.io/permanent-redirect": rd.Spec.To, - "nginx.ingress.kubernetes.io/permanent-redirect-code": strconv.Itoa(code), - } - ingress.Spec = networkingv1.IngressSpec{ - IngressClassName: &r.IngressClass, - TLS: []networkingv1.IngressTLS{ - { - Hosts: []string{rd.Spec.From}, - SecretName: tlsSecretName(rd.Spec.From), - }, - }, - Rules: []networkingv1.IngressRule{ - { - Host: rd.Spec.From, - IngressRuleValue: networkingv1.IngressRuleValue{ - HTTP: &networkingv1.HTTPIngressRuleValue{ - Paths: []networkingv1.HTTPIngressPath{ - { - Path: "/", - PathType: &pathType, - Backend: networkingv1.IngressBackend{ - Service: &networkingv1.IngressServiceBackend{ - Name: dummyBackendName, - Port: networkingv1.ServiceBackendPort{Number: 80}, - }, - }, - }, - }, - }, - }, - }, - }, - } - return nil - }) - return err -} -``` - -Add `"strconv"` to the import block at the top of the file. - -- [ ] **Step 4: Run the tests — expect all to pass** - -```bash -cd /Users/igoramf/projects/deco/operator -go test ./internal/controller/... -v 2>&1 | tail -30 -``` - -Expected: all tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add internal/controller/decoredirect_controller.go \ - internal/controller/decoredirect_controller_test.go -git commit -m "feat(controller): set permanent-redirect-code annotation from spec.redirectCode" -``` - ---- - -## Task 3: HTTP API — expose `redirectCode` in request and response - -**Files:** -- Modify: `internal/api/handlers.go` -- Modify: `internal/api/server_test.go` - -- [ ] **Step 1: Write failing tests for `redirectCode` in POST and GET** - -Add these test functions to `internal/api/server_test.go`: - -```go -func TestCreate_WithRedirectCode(t *testing.T) { - scheme := runtime.NewScheme() - _ = clientgoscheme.AddToScheme(scheme) - _ = decositesv1alpha1.AddToScheme(scheme) - fc := fake.NewClientBuilder().WithScheme(scheme).Build() - h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) - - code := 301 - body, _ := json.Marshal(map[string]interface{}{"from": "example.com", "to": "https://www.example.com", "redirectCode": code}) - req := httptest.NewRequest(http.MethodPost, "/redirects", bytes.NewReader(body)) - req.SetBasicAuth("user", "pass") - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusCreated { - t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String()) - } - - list := &decositesv1alpha1.DecoRedirectList{} - _ = fc.List(context.Background(), list) - if len(list.Items) != 1 { - t.Fatalf("expected 1 item, got %d", len(list.Items)) - } - if list.Items[0].Spec.RedirectCode == nil || *list.Items[0].Spec.RedirectCode != 301 { - t.Fatalf("expected redirectCode=301, got %v", list.Items[0].Spec.RedirectCode) - } -} - -func TestGet_IncludesRedirectCode(t *testing.T) { - scheme := runtime.NewScheme() - _ = clientgoscheme.AddToScheme(scheme) - _ = decositesv1alpha1.AddToScheme(scheme) - code := 301 - fc := fake.NewClientBuilder().WithScheme(scheme).WithObjects(&decositesv1alpha1.DecoRedirect{ - ObjectMeta: metav1.ObjectMeta{Name: "example-com", Namespace: "deco-redirect-system"}, - Spec: decositesv1alpha1.DecoRedirectSpec{ - From: "example.com", - To: "https://www.example.com", - RedirectCode: &code, - }, - }).Build() - h := api.NewHandlers(fc, "deco-redirect-system") - srv := api.NewServer(":0", "user", "pass", h) - - req := httptest.NewRequest(http.MethodGet, "/redirects/example.com", nil) - req.SetBasicAuth("user", "pass") - rec := httptest.NewRecorder() - srv.ServeHTTP(rec, req) - if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d", rec.Code) - } - var item struct { - From string `json:"from"` - RedirectCode *int `json:"redirectCode"` - } - _ = json.NewDecoder(rec.Body).Decode(&item) - if item.RedirectCode == nil || *item.RedirectCode != 301 { - t.Fatalf("expected redirectCode=301 in response, got %v", item.RedirectCode) - } -} -``` - -- [ ] **Step 2: Run the new tests — expect them to fail** - -```bash -cd /Users/igoramf/projects/deco/operator -go test ./internal/api/... -v -run "TestCreate_WithRedirectCode|TestGet_IncludesRedirectCode" 2>&1 -``` - -Expected: FAIL — `RedirectCode` field unknown in request struct. - -- [ ] **Step 3: Update `handlers.go`** - -Replace the full content of `internal/api/handlers.go` with: - -```go -package api - -import ( - "encoding/json" - "net/http" - "regexp" - "strings" - - decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" - apierrors "k8s.io/apimachinery/pkg/api/errors" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" -) - -var domainRe = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$`) - -type Handlers struct { - client client.Client - defaultNamespace string -} - -func NewHandlers(c client.Client, defaultNamespace string) *Handlers { - if defaultNamespace == "" { - defaultNamespace = "deco-redirect-system" - } - return &Handlers{client: c, defaultNamespace: defaultNamespace} -} - -type redirectRequest struct { - From string `json:"from"` - To string `json:"to"` - Namespace string `json:"namespace,omitempty"` - RedirectCode *int `json:"redirectCode,omitempty"` -} - -type redirectResponse struct { - From string `json:"from"` - To string `json:"to"` - RedirectCode *int `json:"redirectCode,omitempty"` - CertificateReady bool `json:"certificateReady"` - Message string `json:"message,omitempty"` - CreatedAt string `json:"createdAt"` -} - -func toResponse(rd *decositesv1alpha1.DecoRedirect) redirectResponse { - resp := redirectResponse{ - From: rd.Spec.From, - To: rd.Spec.To, - RedirectCode: rd.Spec.RedirectCode, - CreatedAt: rd.CreationTimestamp.UTC().Format("2006-01-02T15:04:05Z"), - } - for _, c := range rd.Status.Conditions { - if c.Type == "CertificateReady" { - resp.CertificateReady = c.Status == "True" - if c.Status != "True" { - resp.Message = c.Message - } - break - } - } - return resp -} - -func (h *Handlers) create(w http.ResponseWriter, r *http.Request) { - var req redirectRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "invalid JSON", http.StatusBadRequest) - return - } - from := strings.ToLower(strings.TrimSpace(req.From)) - if !domainRe.MatchString(from) { - http.Error(w, "invalid domain in 'from'", http.StatusBadRequest) - return - } - to := strings.TrimSpace(req.To) - if to == "" { - http.Error(w, "'to' is required", http.StatusBadRequest) - return - } - if !strings.HasPrefix(to, "http://") && !strings.HasPrefix(to, "https://") { - to = "https://" + to - } - ns := h.nsOrDefault(req.Namespace) - - rd := &decositesv1alpha1.DecoRedirect{ - ObjectMeta: metav1.ObjectMeta{ - Name: domainToName(from), - Namespace: ns, - }, - Spec: decositesv1alpha1.DecoRedirectSpec{ - From: from, - To: to, - RedirectCode: req.RedirectCode, - }, - } - if err := h.client.Create(r.Context(), rd); err != nil { - status := http.StatusInternalServerError - if apierrors.IsInvalid(err) { - status = http.StatusUnprocessableEntity - } else if apierrors.IsAlreadyExists(err) { - status = http.StatusConflict - } - http.Error(w, err.Error(), status) - return - } - w.WriteHeader(http.StatusCreated) -} - -func (h *Handlers) get(w http.ResponseWriter, r *http.Request) { - rawDomain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) - if !domainRe.MatchString(rawDomain) { - http.Error(w, "invalid domain", http.StatusBadRequest) - return - } - domain := domainToName(rawDomain) - ns := h.nsOrDefault(r.URL.Query().Get("namespace")) - - rd := &decositesv1alpha1.DecoRedirect{} - if err := h.client.Get(r.Context(), client.ObjectKey{Name: domain, Namespace: ns}, rd); err != nil { - status := http.StatusInternalServerError - if apierrors.IsNotFound(err) { - status = http.StatusNotFound - } - http.Error(w, err.Error(), status) - return - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(toResponse(rd)) -} - -func (h *Handlers) delete(w http.ResponseWriter, r *http.Request) { - rawDomain := strings.ToLower(strings.TrimSpace(r.PathValue("domain"))) - if !domainRe.MatchString(rawDomain) { - http.Error(w, "invalid domain", http.StatusBadRequest) - return - } - domain := domainToName(rawDomain) - ns := h.nsOrDefault(r.URL.Query().Get("namespace")) - - rd := &decositesv1alpha1.DecoRedirect{ - ObjectMeta: metav1.ObjectMeta{Name: domain, Namespace: ns}, - } - if err := h.client.Delete(r.Context(), rd); err != nil { - status := http.StatusInternalServerError - if apierrors.IsNotFound(err) { - status = http.StatusNotFound - } - http.Error(w, err.Error(), status) - return - } - w.WriteHeader(http.StatusNoContent) -} - -func (h *Handlers) list(w http.ResponseWriter, r *http.Request) { - ns := h.nsOrDefault(r.URL.Query().Get("namespace")) - - list := &decositesv1alpha1.DecoRedirectList{} - if err := h.client.List(r.Context(), list, client.InNamespace(ns)); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - items := make([]redirectResponse, len(list.Items)) - for i := range list.Items { - items[i] = toResponse(&list.Items[i]) - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(items) -} - -// domainToName converts a domain to a valid k8s resource name (dots → dashes). -func domainToName(d string) string { - return strings.ReplaceAll(d, ".", "-") -} - -func (h *Handlers) nsOrDefault(ns string) string { - if ns == "" { - return h.defaultNamespace - } - return ns -} -``` - -- [ ] **Step 4: Run all API tests** - -```bash -cd /Users/igoramf/projects/deco/operator -go test ./internal/api/... -v 2>&1 | tail -20 -``` - -Expected: all tests PASS. - -- [ ] **Step 5: Run the full test suite** - -```bash -cd /Users/igoramf/projects/deco/operator -make test 2>&1 | tail -10 -``` - -Expected: PASS, no failures. - -- [ ] **Step 6: Commit** - -```bash -git add internal/api/handlers.go internal/api/server_test.go -git commit -m "feat(api): add redirectCode to DecoRedirect request and response" -``` - ---- - -## Task 4: Helm chart — opt-in `X-Redirect-By` header - -**Files:** -- Create: `chart/templates/configmap-redirect-custom-headers.yaml` -- Modify: `chart/values.yaml` - -- [ ] **Step 1: Add `redirect.decoHeader` to `values.yaml` and wire up `ingress-nginx.controller.config`** - -**Change 1:** In `chart/values.yaml`, add the `decoHeader` block inside the `redirect:` section, after the `clusterIssuer:` block. The full `redirect:` block after the edit: - -```yaml -redirect: - namespace: "deco-redirect-system" - ingressClass: "" # set to enable DecoRedirect controller (e.g. "redirect-nginx") - clusterIssuer: - enabled: false # set true to create the Let's Encrypt ClusterIssuer - name: "" # ClusterIssuer name (e.g. "letsencrypt") - email: "" # required by Let's Encrypt ACME - staging: false # set true to use Let's Encrypt staging (avoids rate limits when testing) - solverAnnotations: {} # extra annotations on the HTTP-01 challenge Ingress - decoHeader: - enabled: false # set true to add X-Redirect-By response header to all redirects served by this nginx instance - value: "deco" # value for the X-Redirect-By header; override to use your own identifier -``` - -**Change 2:** In the `ingress-nginx:` section of `chart/values.yaml`, add `add-headers` to `controller.config`. The full updated `ingress-nginx:` block: - -```yaml -ingress-nginx: - enabled: false - namespaceOverride: "deco-redirect-system" - controller: - ingressClass: redirect-nginx - ingressClassResource: - name: redirect-nginx - controllerValue: "k8s.io/redirect-nginx" - service: - annotations: {} - config: - # Set to "/deco-custom-headers" when redirect.decoHeader.enabled=true. - # The ConfigMap is only created when redirect.decoHeader.enabled=true. - add-headers: "" -``` - -- [ ] **Step 2: Create the conditional ConfigMap template** - -Create file `chart/templates/configmap-redirect-custom-headers.yaml`: - -```yaml -{{- if .Values.redirect.decoHeader.enabled }} -apiVersion: v1 -kind: ConfigMap -metadata: - name: deco-custom-headers - namespace: {{ .Values.redirect.namespace }} -data: - X-Redirect-By: {{ .Values.redirect.decoHeader.value | quote }} -{{- end }} -``` - -- [ ] **Step 3: Verify the template renders correctly when enabled** - -```bash -cd /Users/igoramf/projects/deco/operator -helm template test chart/ --set redirect.decoHeader.enabled=true --set redirect.decoHeader.value=deco 2>&1 | grep -A 8 "deco-custom-headers" -``` - -Expected output: -```yaml -# Source: deco-operator/templates/configmap-redirect-custom-headers.yaml -apiVersion: v1 -kind: ConfigMap -metadata: - name: deco-custom-headers - namespace: deco-redirect-system -data: - X-Redirect-By: "deco" -``` - -- [ ] **Step 4: Verify the template renders nothing when disabled (default)** - -```bash -cd /Users/igoramf/projects/deco/operator -helm template test chart/ 2>&1 | grep -c "deco-custom-headers" -``` - -Expected output: `0` (ConfigMap not present in rendered output) - -- [ ] **Step 5: Lint the chart** - -```bash -cd /Users/igoramf/projects/deco/operator -helm lint chart/ -``` - -Expected: `1 chart(s) linted, 0 chart(s) failed` - -- [ ] **Step 6: Commit** - -```bash -git add chart/templates/configmap-redirect-custom-headers.yaml chart/values.yaml -git commit -m "feat(chart): add opt-in X-Redirect-By header via redirect.decoHeader" -``` - ---- - -## Final check - -- [ ] **Run full test suite one last time** - -```bash -cd /Users/igoramf/projects/deco/operator -make test 2>&1 | tail -10 -``` - -Expected: all tests pass, coverage written to `cover.out`. - -- [ ] **Verify the branch is clean** - -```bash -git log --oneline main..HEAD -``` - -Expected (4 commits on top of main): -``` - feat(chart): add opt-in X-Redirect-By header via redirect.decoHeader - feat(api): add redirectCode to DecoRedirect request and response - feat(controller): set permanent-redirect-code annotation from spec.redirectCode - feat(crd): add redirectCode field (enum 301|307, default 307) to DecoRedirect -``` diff --git a/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md b/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md deleted file mode 100644 index 5e4fc95..0000000 --- a/docs/superpowers/specs/2026-05-28-decoredirect-redirect-code-header-design.md +++ /dev/null @@ -1,139 +0,0 @@ -# DecoRedirect: redirectCode field + X-Redirect-By header - -**Date:** 2026-05-28 -**Status:** Approved - ---- - -## Problem - -The `DecoRedirect` CRD currently hard-codes a 301 redirect via the `permanent-redirect` nginx annotation. There is no way to configure the redirect code per client, and no response header to identify that a redirect was served by Deco. - ---- - -## Goals - -1. Add a `redirectCode` field to `DecoRedirectSpec` accepting `301` or `307`, defaulting to `307`. -2. Validate the field at the CRD level (kubebuilder enum) so invalid values are rejected by the API server. -3. Add `X-Redirect-By: deco` to all redirect responses via nginx `add-headers`. - ---- - -## Design - -### 1. CRD — `redirectCode` field - -Add to `DecoRedirectSpec`: - -```go -// RedirectCode is the HTTP status code used for the redirect. Must be 301 or 307. -// Defaults to 307 if not set. -// +kubebuilder:validation:Enum=301;307 -// +kubebuilder:default=307 -// +optional -RedirectCode *int `json:"redirectCode,omitempty"` -``` - -- Use a pointer (`*int`) so the controller can distinguish "not set" (nil) from an explicit value. -- `+kubebuilder:default=307` makes the API server inject 307 on new CREATE requests when the field is omitted. -- Existing CRs (created before this change) will have `nil` at read time — the controller treats `nil` as 307. -- Validation is enforced by the Kubernetes API server via the generated CRD schema — no webhook needed. -- The HTTP API (`redirectRequest` / `redirectResponse`) exposes `redirectCode` as an optional `*int`; the handler passes it through to the CR spec. - -### 2. Controller — `reconcileIngress` change - -In `reconcileIngress`, set both annotations per Ingress: - -```go -code := 307 -if rd.Spec.RedirectCode != nil { - code = *rd.Spec.RedirectCode -} -ingress.Annotations = map[string]string{ - "nginx.ingress.kubernetes.io/permanent-redirect": rd.Spec.To, - "nginx.ingress.kubernetes.io/permanent-redirect-code": strconv.Itoa(code), -} -``` - -`permanent-redirect-code` is a per-Ingress annotation — each client's Ingress carries its own value, so 301 and 307 clients coexist without conflict. - -### 3. Header — Helm chart changes (opt-in) - -This is an open-source chart. The header feature must be opt-in, parallel to `ingress-nginx.enabled`. A new value gates both the ConfigMap and the nginx config entry: - -```yaml -# values.yaml -redirect: - decoHeader: - enabled: true # set to false to disable X-Redirect-By header entirely - value: "deco" # value for the X-Redirect-By header -``` - -**New ConfigMap** (rendered conditionally in the chart): - -```yaml -{{- if .Values.redirect.decoHeader.enabled }} -apiVersion: v1 -kind: ConfigMap -metadata: - name: deco-custom-headers - namespace: {{ .Values.redirect.namespace }} -data: - X-Redirect-By: {{ .Values.redirect.decoHeader.value | quote }} -{{- end }} -``` - -**nginx values** — the `add-headers` key is only injected when the feature is enabled. This is done by merging it into `ingress-nginx.controller.config` conditionally in the chart templates, not in `values.yaml`, so that consumers who set `redirect.decoHeader.enabled: false` are not affected. - -The nginx ingress controller reads the ConfigMap at startup and appends the headers to every response. Since this nginx instance is exclusively used for Deco redirects, a global header is correct behavior. - ---- - -## HTTP API changes - -`POST /redirects` request body gains an optional field: - -```json -{ - "from": "client.com", - "to": "https://www.client.com", - "redirectCode": 307 -} -``` - -`GET /redirects` and `GET /redirects/{domain}` response gains: - -```json -{ - "from": "client.com", - "to": "https://www.client.com", - "redirectCode": 307, - "certificateReady": true, - "createdAt": "2026-05-28T00:00:00Z" -} -``` - -Omitting `redirectCode` in POST defaults to `307` (kubebuilder default). -Invalid values (`302`, `308`, etc.) return `422 Unprocessable Entity` from the API server. - ---- - -## Files to change - -| File | Change | -|------|--------| -| `api/v1alpha1/decoredirect_types.go` | Add `RedirectCode int` field with kubebuilder markers | -| `internal/controller/decoredirect_controller.go` | Set `permanent-redirect-code` annotation in `reconcileIngress` | -| `internal/api/handlers.go` | Add `redirectCode` to `redirectRequest` and `redirectResponse`; pass through in `create`; render in `toResponse` | -| `internal/controller/decoredirect_controller_test.go` | Update/add tests for redirect code annotation | -| `internal/api/server_test.go` | Update/add tests for redirectCode in request/response | -| `chart/` | Add conditional ConfigMap template for `deco-custom-headers`; add `redirect.decoHeader.enabled` value; conditionally inject `add-headers` into nginx controller config | -| `config/crd/bases/` | Regenerate via `make generate manifests` | - ---- - -## Out of scope - -- Supporting redirect codes other than 301 and 307. -- Per-client header values. -- Migrating existing CRs — existing CRs will have `nil` for `redirectCode`; the controller treats `nil` as 307. No explicit migration needed. From 9a39a7f81937a8d5ec532e711edbdc056efec23c Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 17:14:42 -0300 Subject: [PATCH 13/16] chore(chart): improve NOTES.txt for decoHeader feature --- chart/templates/NOTES.txt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 1d1a863..236abe8 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -1,5 +1,7 @@ -{{- if and .Values.redirect.decoHeader.enabled (eq (index .Values "ingress-nginx" "controller" "config" "add-headers") "") }} -WARNING: redirect.decoHeader.enabled is true but ingress-nginx.controller.config.add-headers -is not set. The X-Redirect-By header will not be added until you also set: +{{- if .Values.redirect.decoHeader.enabled }} +X-Redirect-By header is enabled. All apex redirect responses will include: + X-Redirect-By: {{ .Values.redirect.decoHeader.value }} + +To activate it, make sure the nginx controller is configured to read the header ConfigMap: ingress-nginx.controller.config.add-headers: "{{ .Values.redirect.namespace }}/deco-custom-headers" {{- end }} From b63d75b49ea540c9dd6105672b209a6b605ec9c7 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 17:19:48 -0300 Subject: [PATCH 14/16] feat(chart): generalize decoHeader to redirect.customHeaders map --- chart/templates/NOTES.txt | 12 +-- .../configmap-redirect-custom-headers.yaml | 8 +- chart/values.yaml | 14 ++-- hack/helm-generator/main.go | 8 +- values-local.yaml | 75 +++++++++++++++++++ 5 files changed, 101 insertions(+), 16 deletions(-) create mode 100644 values-local.yaml diff --git a/chart/templates/NOTES.txt b/chart/templates/NOTES.txt index 236abe8..c41b018 100644 --- a/chart/templates/NOTES.txt +++ b/chart/templates/NOTES.txt @@ -1,7 +1,9 @@ -{{- if .Values.redirect.decoHeader.enabled }} -X-Redirect-By header is enabled. All apex redirect responses will include: - X-Redirect-By: {{ .Values.redirect.decoHeader.value }} +{{- if .Values.redirect.customHeaders.enabled }} +Custom response headers are enabled for apex redirects: +{{- range $key, $val := .Values.redirect.customHeaders.headers }} + {{ $key }}: {{ $val }} +{{- end }} -To activate it, make sure the nginx controller is configured to read the header ConfigMap: - ingress-nginx.controller.config.add-headers: "{{ .Values.redirect.namespace }}/deco-custom-headers" +To activate them, make sure the nginx controller is configured to read the header ConfigMap: + ingress-nginx.controller.config.add-headers: "{{ .Values.redirect.namespace }}/redirect-custom-headers" {{- end }} diff --git a/chart/templates/configmap-redirect-custom-headers.yaml b/chart/templates/configmap-redirect-custom-headers.yaml index c536c69..7acbac7 100644 --- a/chart/templates/configmap-redirect-custom-headers.yaml +++ b/chart/templates/configmap-redirect-custom-headers.yaml @@ -1,9 +1,11 @@ -{{- if .Values.redirect.decoHeader.enabled }} +{{- if and .Values.redirect.customHeaders.enabled .Values.redirect.customHeaders.headers }} apiVersion: v1 kind: ConfigMap metadata: - name: deco-custom-headers + name: redirect-custom-headers namespace: {{ .Values.redirect.namespace }} data: - X-Redirect-By: {{ .Values.redirect.decoHeader.value | quote }} + {{- range $key, $val := .Values.redirect.customHeaders.headers }} + {{ $key }}: {{ $val | quote }} + {{- end }} {{- end }} diff --git a/chart/values.yaml b/chart/values.yaml index 0b0af38..e90d429 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -146,9 +146,13 @@ redirect: email: "" # required by Let's Encrypt ACME staging: false # set true to use Let's Encrypt staging (avoids rate limits when testing) solverAnnotations: {} # extra annotations on the HTTP-01 challenge Ingress (e.g. nginx.ingress.kubernetes.io/ssl-redirect: "false") - decoHeader: - enabled: false # set true to add X-Redirect-By response header to all redirects served by this nginx instance - value: "deco" # value for the X-Redirect-By header; override to use your own identifier + customHeaders: + enabled: false # set true to inject custom response headers into all redirects served by this nginx instance + headers: {} # map of header name → value; e.g. X-Redirect-By: "deco" + # Example: + # headers: + # X-Redirect-By: "deco" + # X-Powered-By: "myplatform" # ingress-nginx subchart (opt-in) — set enabled: true to deploy nginx alongside the operator. # namespaceOverride isolates nginx from the operator namespace. @@ -164,8 +168,8 @@ ingress-nginx: service: annotations: {} config: - # Set to "/deco-custom-headers" when redirect.decoHeader.enabled=true. - # The ConfigMap is only created when redirect.decoHeader.enabled=true. + # Set to "/redirect-custom-headers" when redirect.customHeaders.enabled=true. + # The ConfigMap is only created when redirect.customHeaders.enabled=true. add-headers: "" # Name overrides diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 57448eb..aeac293 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -403,14 +403,16 @@ spec: } func addRedirectCustomHeaders(templatesDir string) error { - content := `{{- if .Values.redirect.decoHeader.enabled }} + content := `{{- if and .Values.redirect.customHeaders.enabled .Values.redirect.customHeaders.headers }} apiVersion: v1 kind: ConfigMap metadata: - name: deco-custom-headers + name: redirect-custom-headers namespace: {{ .Values.redirect.namespace }} data: - X-Redirect-By: {{ .Values.redirect.decoHeader.value | quote }} + {{- range $key, $val := .Values.redirect.customHeaders.headers }} + {{ $key }}: {{ $val | quote }} + {{- end }} {{- end }} ` return os.WriteFile(filepath.Join(templatesDir, "configmap-redirect-custom-headers.yaml"), []byte(content), 0644) diff --git a/values-local.yaml b/values-local.yaml new file mode 100644 index 0000000..1547d9f --- /dev/null +++ b/values-local.yaml @@ -0,0 +1,75 @@ +image: + repository: operator + tag: local + pullPolicy: Never + +replicaCount: 1 + + +redirect: + namespace: "deco-redirect-system" + ingressClass: "redirect-nginx" + clusterIssuer: + enabled: false + name: "letsencrypt" + customHeaders: + enabled: true + headers: + X-Redirect-By: "deco" + +ingress-nginx: + enabled: true + namespaceOverride: "deco-redirect-system" + controller: + ingressClass: redirect-nginx + ingressClassResource: + name: redirect-nginx + controllerValue: "k8s.io/redirect-nginx" + admissionWebhooks: + enabled: false + service: + annotations: {} + config: + add-headers: "deco-redirect-system/redirect-custom-headers" + +operatorApi: + addr: ":9090" + existingSecret: "operator-api-credentials" + +certManager: + enabled: false + +webhook: + enabled: false +leaderElection: + enabled: false + +github: + existingSecret: deco-operator-github-token + existingSecretKey: token + +valkey: + sentinelUrls: "" + +cfworkers: + existingSecret: deco-operator-cfworkers + artifactsBucket: new-deco-cfworkers-deployments + builderImage: "igoramf/cfworkers-builder:v8" + +s3: + region: us-west-2 + logsBucket: new-deco-sites-build-logs + stateBucket: new-deco-admin-states + +podAnnotations: + prometheus.io/scrape: "true" + prometheus.io/port: "8080" + prometheus.io/path: "/metrics" + +resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 1000m + memory: 1Gi From a7ea51408f4b25b3daa8116d8eca0a7a6041d01b Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 17:40:13 -0300 Subject: [PATCH 15/16] chore: remove values-local.yaml from tracking, add to .gitignore --- .gitignore | 1 + values-local.yaml | 75 ----------------------------------------------- 2 files changed, 1 insertion(+), 75 deletions(-) delete mode 100644 values-local.yaml diff --git a/.gitignore b/.gitignore index 9753218..770db48 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ go.work *.swp *.swo *~ +values-local.yaml diff --git a/values-local.yaml b/values-local.yaml deleted file mode 100644 index 1547d9f..0000000 --- a/values-local.yaml +++ /dev/null @@ -1,75 +0,0 @@ -image: - repository: operator - tag: local - pullPolicy: Never - -replicaCount: 1 - - -redirect: - namespace: "deco-redirect-system" - ingressClass: "redirect-nginx" - clusterIssuer: - enabled: false - name: "letsencrypt" - customHeaders: - enabled: true - headers: - X-Redirect-By: "deco" - -ingress-nginx: - enabled: true - namespaceOverride: "deco-redirect-system" - controller: - ingressClass: redirect-nginx - ingressClassResource: - name: redirect-nginx - controllerValue: "k8s.io/redirect-nginx" - admissionWebhooks: - enabled: false - service: - annotations: {} - config: - add-headers: "deco-redirect-system/redirect-custom-headers" - -operatorApi: - addr: ":9090" - existingSecret: "operator-api-credentials" - -certManager: - enabled: false - -webhook: - enabled: false -leaderElection: - enabled: false - -github: - existingSecret: deco-operator-github-token - existingSecretKey: token - -valkey: - sentinelUrls: "" - -cfworkers: - existingSecret: deco-operator-cfworkers - artifactsBucket: new-deco-cfworkers-deployments - builderImage: "igoramf/cfworkers-builder:v8" - -s3: - region: us-west-2 - logsBucket: new-deco-sites-build-logs - stateBucket: new-deco-admin-states - -podAnnotations: - prometheus.io/scrape: "true" - prometheus.io/port: "8080" - prometheus.io/path: "/metrics" - -resources: - requests: - cpu: 250m - memory: 256Mi - limits: - cpu: 1000m - memory: 1Gi From 14f34d47e2a4852d859b711d764b19033ead3d81 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 18:00:17 -0300 Subject: [PATCH 16/16] chore(chart): rename ConfigMap to redirect-response-headers --- chart/templates/configmap-redirect-custom-headers.yaml | 2 +- chart/values.yaml | 2 +- hack/helm-generator/main.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/chart/templates/configmap-redirect-custom-headers.yaml b/chart/templates/configmap-redirect-custom-headers.yaml index 7acbac7..11ac2bf 100644 --- a/chart/templates/configmap-redirect-custom-headers.yaml +++ b/chart/templates/configmap-redirect-custom-headers.yaml @@ -2,7 +2,7 @@ apiVersion: v1 kind: ConfigMap metadata: - name: redirect-custom-headers + name: redirect-response-headers namespace: {{ .Values.redirect.namespace }} data: {{- range $key, $val := .Values.redirect.customHeaders.headers }} diff --git a/chart/values.yaml b/chart/values.yaml index e90d429..eb3be50 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -168,7 +168,7 @@ ingress-nginx: service: annotations: {} config: - # Set to "/redirect-custom-headers" when redirect.customHeaders.enabled=true. + # Set to "/redirect-response-headers" when redirect.customHeaders.enabled=true. # The ConfigMap is only created when redirect.customHeaders.enabled=true. add-headers: "" diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index aeac293..56c7cb8 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -407,7 +407,7 @@ func addRedirectCustomHeaders(templatesDir string) error { apiVersion: v1 kind: ConfigMap metadata: - name: redirect-custom-headers + name: redirect-response-headers namespace: {{ .Values.redirect.namespace }} data: {{- range $key, $val := .Values.redirect.customHeaders.headers }}