Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d46f460
docs: add spec for DecoRedirect redirectCode field and X-Redirect-By …
igoramf May 28, 2026
6927142
docs: make decoHeader opt-in in spec (open-source chart concern)
igoramf May 28, 2026
aa32ebb
docs: make X-Redirect-By value configurable with default 'deco'
igoramf May 28, 2026
5147cac
docs: add implementation plan for DecoRedirect redirectCode + X-Redir…
igoramf May 28, 2026
ef7f6ea
feat(crd): add redirectCode field (enum 301|307, default 307) to Deco…
igoramf May 28, 2026
3e6ea45
feat(controller): set permanent-redirect-code annotation from spec.re…
igoramf May 28, 2026
9f04204
feat(api): add redirectCode to DecoRedirect request and response
igoramf May 28, 2026
e8c5485
docs(api): document that redirectCode validation is delegated to CRD …
igoramf May 28, 2026
0b9121a
feat(chart): add opt-in X-Redirect-By header via redirect.decoHeader
igoramf May 28, 2026
5a346f7
feat(chart): warn when decoHeader enabled but add-headers not configured
igoramf May 28, 2026
41eb072
fix(chart): generate configmap-redirect-custom-headers via helm gener…
igoramf May 28, 2026
5112c3c
chore: remove superpowers plans and spec docs
igoramf May 28, 2026
9a39a7f
chore(chart): improve NOTES.txt for decoHeader feature
igoramf May 28, 2026
b63d75b
feat(chart): generalize decoHeader to redirect.customHeaders map
igoramf May 28, 2026
a7ea514
chore: remove values-local.yaml from tracking, add to .gitignore
igoramf May 28, 2026
14f34d4
chore(chart): rename ConfigMap to redirect-response-headers
igoramf May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ go.work
*.swp
*.swo
*~
values-local.yaml
7 changes: 7 additions & 0 deletions api/v1alpha1/decoredirect_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 6 additions & 1 deletion api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions chart/templates/NOTES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{{- 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 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 }}
11 changes: 11 additions & 0 deletions chart/templates/configmap-redirect-custom-headers.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{{- if and .Values.redirect.customHeaders.enabled .Values.redirect.customHeaders.headers }}
apiVersion: v1
kind: ConfigMap
metadata:
name: redirect-response-headers
namespace: {{ .Values.redirect.namespace }}
data:
{{- range $key, $val := .Values.redirect.customHeaders.headers }}
{{ $key }}: {{ $val | quote }}
{{- end }}
{{- end }}
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down
11 changes: 11 additions & 0 deletions chart/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +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")
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.
Expand All @@ -160,6 +167,10 @@ ingress-nginx:
controllerValue: "k8s.io/redirect-nginx"
service:
annotations: {}
config:
# Set to "<redirect.namespace>/redirect-response-headers" when redirect.customHeaders.enabled=true.
# The ConfigMap is only created when redirect.customHeaders.enabled=true.
add-headers: ""

# Name overrides
nameOverride: ""
Expand Down
9 changes: 9 additions & 0 deletions config/crd/bases/deco.sites_decoredict.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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").
Expand Down
19 changes: 19 additions & 0 deletions hack/helm-generator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -399,6 +402,22 @@ spec:
return os.WriteFile(filepath.Join(templatesDir, "clusterissuer-letsencrypt.yaml"), []byte(content), 0644)
}

func addRedirectCustomHeaders(templatesDir string) error {
content := `{{- if and .Values.redirect.customHeaders.enabled .Values.redirect.customHeaders.headers }}
apiVersion: v1
kind: ConfigMap
metadata:
name: redirect-response-headers
namespace: {{ .Values.redirect.namespace }}
data:
{{- 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)
}

func addRedirectControllerArgs(templatesDir string) error {
files, err := filepath.Glob(filepath.Join(templatesDir, "deployment-*.yaml"))
if err != nil || len(files) == 0 {
Expand Down
21 changes: 13 additions & 8 deletions internal/api/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,24 +27,27 @@ 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"`
}

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" {
Expand Down Expand Up @@ -85,10 +88,12 @@ 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,
},
}
// 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) {
Expand Down
61 changes: 61 additions & 0 deletions internal/api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
11 changes: 9 additions & 2 deletions internal/controller/decoredirect_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"crypto/sha256"
"fmt"
"strconv"
"strings"
"time"

Expand Down Expand Up @@ -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,
Expand Down
49 changes: 49 additions & 0 deletions internal/controller/decoredirect_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -134,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"))
})
})
})
Loading