From c0d1d134dfddd746166e948a4feedd175ecea6ce Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 18:46:34 -0300 Subject: [PATCH 01/13] docs: add spec for controller feature flags --- ...6-05-28-controller-feature-flags-design.md | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-controller-feature-flags-design.md diff --git a/docs/superpowers/specs/2026-05-28-controller-feature-flags-design.md b/docs/superpowers/specs/2026-05-28-controller-feature-flags-design.md new file mode 100644 index 0000000..9a60bb2 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-controller-feature-flags-design.md @@ -0,0 +1,184 @@ +# Controller Feature Flags — Design + +**Date:** 2026-05-28 +**Status:** Approved + +--- + +## Problem + +The operator bundles multiple independent controllers (Decofile, Deco builds, DecoRedirect, Namespace/Valkey, HTTP API). Not all clusters need all controllers — the hub cluster only needs `decoredirect` and `operator-api`, but today all controllers start unconditionally. + +This causes two problems: +1. Controllers that watch CRDs not installed in the cluster (e.g., Knative `Revision` in `DecofileReconciler`) crash the operator on startup. +2. There is no way to scope a deployment to only the features a cluster actually uses. + +--- + +## Goal + +Add a `controllers.enabled` list to the Helm values that controls which controllers are registered at startup. Default is `["*"]` (all controllers — preserves existing behavior). Unknown names are fatal errors. + +--- + +## Design + +### 1. Controller Name Constants + +Each controller defines a `ControllerName` string constant in its own file, co-located with the reconciler. This makes the name explicit, discoverable, and compiler-checked. + +```go +// internal/controller/decoredirect_controller.go +const ControllerName = "decoredirect" + +// internal/controller/decofile_controller.go +const ControllerName = "decofile" + +// internal/controller/deco_controller.go +const ControllerName = "deco" + +// internal/controller/namespace_controller.go +const ControllerName = "namespace" +``` + +For the HTTP API (not a controller-runtime reconciler): +```go +// internal/api/server.go +const ControllerName = "operator-api" +``` + +### 2. CLI Flag + +In `cmd/main.go`, add a `--controllers` flag: + +```go +var controllersFlag string +flag.StringVar(&controllersFlag, "controllers", "*", + `Comma-separated list of controllers to enable. Use "*" to enable all. +Valid values: namespace, decofile, deco, decoredirect, operator-api`) +``` + +### 3. Controller Set Logic + +A small helper in `cmd/main.go` (or a new `cmd/controllers.go`) parses the flag and returns an `enabled(name string) bool` function: + +```go +var knownControllers = []string{ + controller.NamespaceControllerName, + controller.DecofileControllerName, + controller.DecoControllerName, + controller.DecoRedirectControllerName, + api.ControllerName, +} + +func parseControllers(flag string) (func(string) bool, error) { + if flag == "*" { + return func(string) bool { return true }, nil + } + requested := strings.Split(flag, ",") + set := make(map[string]bool, len(requested)) + for _, name := range requested { + name = strings.TrimSpace(name) + if !slices.Contains(knownControllers, name) { + return nil, fmt.Errorf("unknown controller %q, valid values: %s", + name, strings.Join(knownControllers, ", ")) + } + set[name] = true + } + return func(name string) bool { return set[name] }, nil +} +``` + +Unknown name → returns error → `main` logs and calls `os.Exit(1)`. + +### 4. Gating Each Controller in `main.go` + +Each `SetupWithManager` / `mgr.Add` call is wrapped with the `enabled` check: + +```go +enabled, err := parseControllers(controllersFlag) +if err != nil { + setupLog.Error(err, "invalid --controllers flag") + os.Exit(1) +} + +if enabled(controller.NamespaceControllerName) { + if err := nsReconciler.SetupWithManager(mgr); err != nil { ... } +} + +if enabled(controller.DecofileControllerName) { + if err := (&controller.DecofileReconciler{...}).SetupWithManager(mgr); err != nil { ... } +} + +// etc. +``` + +### 5. Helm Chart + +**`values.yaml`** — new top-level block: + +```yaml +# Controllers to enable at startup. +# Use ["*"] to enable all (default — preserves existing behavior). +# Valid values: namespace, decofile, deco, decoredirect, operator-api +controllers: + enabled: + - "*" +``` + +**`deployment-operator-controller-manager.yaml`** (via helm-generator) — inject arg when not `["*"]`: + +```yaml +{{- if not (eq (index .Values.controllers.enabled 0) "*") }} +- --controllers={{ join "," .Values.controllers.enabled }} +{{- end }} +``` + +When `enabled: ["*"]`, no flag is injected and the default (`"*"`) applies. + +--- + +## Usage Examples + +**Hub cluster (`infra_applications/hub/values.yaml`):** +```yaml +controllers: + enabled: + - decoredirect + - operator-api +``` + +**Spoke cluster (all controllers):** +```yaml +controllers: + enabled: + - "*" # or omit entirely — same effect +``` + +**Unknown name (fatal at startup):** +``` +ERROR invalid --controllers flag {"error": "unknown controller \"xpto\", valid values: namespace, decofile, deco, decoredirect, operator-api"} +``` + +--- + +## Files to Change + +| File | Change | +|------|--------| +| `internal/controller/decoredirect_controller.go` | Add `ControllerName = "decoredirect"` | +| `internal/controller/decofile_controller.go` | Add `ControllerName = "decofile"` | +| `internal/controller/deco_controller.go` | Add `ControllerName = "deco"` | +| `internal/controller/namespace_controller.go` | Add `ControllerName = "namespace"` | +| `internal/api/server.go` | Add `ControllerName = "operator-api"` | +| `cmd/main.go` | Add `--controllers` flag + `parseControllers` + gate each controller | +| `chart/values.yaml` | Add `controllers.enabled: ["*"]` | +| `hack/helm-generator/main.go` | Inject `--controllers` arg in deployment when not `*` | + +--- + +## Out of Scope + +- Disabling individual sub-features within a controller (e.g., Knative watch inside Decofile) +- Dynamic reconfiguration at runtime (restart required to change active controllers) +- `--controllers=*,-decofile` exclusion syntax (list-only for now; can be added later) From cde1062093277b037fffe31cdfd366091ae28b36 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 18:51:11 -0300 Subject: [PATCH 02/13] docs: add plan and spec for controller feature flags --- .../2026-05-28-controller-feature-flags.md | 601 ++++++++++++++++++ 1 file changed, 601 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-controller-feature-flags.md diff --git a/docs/superpowers/plans/2026-05-28-controller-feature-flags.md b/docs/superpowers/plans/2026-05-28-controller-feature-flags.md new file mode 100644 index 0000000..d5c1ad6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-controller-feature-flags.md @@ -0,0 +1,601 @@ +# Controller Feature Flags — 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 `--controllers` flag to the operator and a `controllers.enabled` Helm value so each cluster deployment can opt-in to only the controllers it needs. + +**Architecture:** Each controller defines a `ControllerName` string constant. A `parseControllers` helper in `cmd/controllers.go` parses the comma-separated flag (supporting `"*"` for all), validates names against a registry of known controllers, and returns an `enabled(name string) bool` function. `main.go` wraps each controller's setup with this check. The Helm chart injects `--controllers=` into the deployment args. + +**Tech Stack:** Go 1.24, controller-runtime, Helm 3, Sprig template functions (`join`). + +--- + +## File Map + +| File | Action | Purpose | +|------|--------|---------| +| `internal/controller/decoredirect_controller.go` | Modify | Add `ControllerName = "decoredirect"` | +| `internal/controller/decofile_controller.go` | Modify | Add `ControllerName = "decofile"` | +| `internal/controller/deco_controller.go` | Modify | Add `ControllerName = "deco"` | +| `internal/controller/namespace_controller.go` | Modify | Add `ControllerName = "namespace"` | +| `internal/api/server.go` | Modify | Add `ControllerName = "operator-api"` | +| `cmd/controllers.go` | Create | `parseControllers` function + `knownControllers` registry | +| `cmd/controllers_test.go` | Create | Unit tests for `parseControllers` | +| `cmd/main.go` | Modify | Add `--controllers` flag, call `parseControllers`, gate each controller | +| `chart/values.yaml` | Modify | Add `controllers.enabled: ["*"]` | +| `hack/helm-generator/main.go` | Modify | Inject `--controllers` arg into deployment | + +--- + +## Task 1: Add ControllerName constants + +**Files:** +- Modify: `internal/controller/decoredirect_controller.go` +- Modify: `internal/controller/decofile_controller.go` +- Modify: `internal/controller/deco_controller.go` +- Modify: `internal/controller/namespace_controller.go` +- Modify: `internal/api/server.go` + +All four controllers live in `package controller` — each constant needs a unique name to avoid conflicts. + +- [ ] **Step 1: Add constant to `decoredirect_controller.go`** + +After the existing `const dummyBackendName = "redirect-dummy-backend"` line, add: + +```go +const DecoRedirectControllerName = "decoredirect" +``` + +- [ ] **Step 2: Add constant to `decofile_controller.go`** + +Add to the existing `const` block at the top of the file: + +```go +const DecofileControllerName = "decofile" +``` + +- [ ] **Step 3: Add constant to `deco_controller.go`** + +Add to the existing `const` block: + +```go +const DecoControllerName = "deco" +``` + +- [ ] **Step 4: Add constant to `namespace_controller.go`** + +Add to the existing `const` block: + +```go +const NamespaceControllerName = "namespace" +``` + +- [ ] **Step 5: Add constant to `internal/api/server.go`** + +After the package declaration, add: + +```go +const ControllerName = "operator-api" +``` + +- [ ] **Step 6: Verify the code compiles** + +```bash +cd /Users/igoramf/projects/deco/operator +go build ./... +``` + +Expected: exits 0, no errors. + +- [ ] **Step 7: Commit** + +```bash +git add internal/controller/decoredirect_controller.go \ + internal/controller/decofile_controller.go \ + internal/controller/deco_controller.go \ + internal/controller/namespace_controller.go \ + internal/api/server.go +git commit -m "feat(controllers): add ControllerName constants to each controller" +``` + +--- + +## Task 2: `parseControllers` function with tests + +**Files:** +- Create: `cmd/controllers.go` +- Create: `cmd/controllers_test.go` + +- [ ] **Step 1: Write the failing tests** + +Create `cmd/controllers_test.go`: + +```go +package main + +import ( + "strings" + "testing" + + "github.com/deco-sites/decofile-operator/internal/api" + "github.com/deco-sites/decofile-operator/internal/controller" +) + +func TestParseControllers_Star(t *testing.T) { + enabled, err := parseControllers("*") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, name := range []string{ + controller.NamespaceControllerName, + controller.DecofileControllerName, + controller.DecoControllerName, + controller.DecoRedirectControllerName, + api.ControllerName, + } { + if !enabled(name) { + t.Errorf("expected %q to be enabled with *, but it wasn't", name) + } + } +} + +func TestParseControllers_Subset(t *testing.T) { + enabled, err := parseControllers("decoredirect,operator-api") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !enabled(controller.DecoRedirectControllerName) { + t.Error("expected decoredirect to be enabled") + } + if !enabled(api.ControllerName) { + t.Error("expected operator-api to be enabled") + } + if enabled(controller.DecofileControllerName) { + t.Error("expected decofile to be disabled") + } + if enabled(controller.NamespaceControllerName) { + t.Error("expected namespace to be disabled") + } + if enabled(controller.DecoControllerName) { + t.Error("expected deco to be disabled") + } +} + +func TestParseControllers_UnknownName(t *testing.T) { + _, err := parseControllers("decoredirect,xpto") + if err == nil { + t.Fatal("expected error for unknown controller name, got nil") + } + if !strings.Contains(err.Error(), "xpto") { + t.Errorf("expected error to mention 'xpto', got: %v", err) + } +} + +func TestParseControllers_WhitespaceHandled(t *testing.T) { + enabled, err := parseControllers("decoredirect, operator-api") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !enabled(controller.DecoRedirectControllerName) { + t.Error("expected decoredirect to be enabled") + } + if !enabled(api.ControllerName) { + t.Error("expected operator-api to be enabled") + } +} +``` + +- [ ] **Step 2: Run tests — expect compile error** (function not defined yet) + +```bash +cd /Users/igoramf/projects/deco/operator +go test ./cmd/... 2>&1 | head -10 +``` + +Expected: compile error `undefined: parseControllers` + +- [ ] **Step 3: Create `cmd/controllers.go`** + +```go +package main + +import ( + "fmt" + "slices" + "strings" + + "github.com/deco-sites/decofile-operator/internal/api" + "github.com/deco-sites/decofile-operator/internal/controller" +) + +var knownControllers = []string{ + controller.NamespaceControllerName, + controller.DecofileControllerName, + controller.DecoControllerName, + controller.DecoRedirectControllerName, + api.ControllerName, +} + +// parseControllers parses a comma-separated list of controller names. +// "*" enables all known controllers. +// Returns an error if any name is not in knownControllers. +func parseControllers(flag string) (func(string) bool, error) { + if strings.TrimSpace(flag) == "*" { + return func(string) bool { return true }, nil + } + parts := strings.Split(flag, ",") + set := make(map[string]bool, len(parts)) + for _, name := range parts { + name = strings.TrimSpace(name) + if !slices.Contains(knownControllers, name) { + return nil, fmt.Errorf("unknown controller %q; valid values: %s", + name, strings.Join(knownControllers, ", ")) + } + set[name] = true + } + return func(name string) bool { return set[name] }, nil +} +``` + +- [ ] **Step 4: Run tests — expect all to pass** + +```bash +cd /Users/igoramf/projects/deco/operator +go test ./cmd/... -v 2>&1 | tail -15 +``` + +Expected: all 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add cmd/controllers.go cmd/controllers_test.go +git commit -m "feat(controllers): add parseControllers with knownControllers registry" +``` + +--- + +## Task 3: Wire `--controllers` flag in `main.go` + +**Files:** +- Modify: `cmd/main.go` + +Note: `main.go` is restructured to scope each controller's variables inside its own `if enabled(...)` block. + +- [ ] **Step 1: Add the `--controllers` flag after `flag.StringVar(&redirectClusterIssuer, ...)` (around line 137)** + +Add this variable declaration at the top of `main()` with the other vars: + +```go +var controllersFlag string +``` + +Add this flag definition after the `redirectClusterIssuer` flag (before `opts.BindFlags`): + +```go +flag.StringVar(&controllersFlag, "controllers", "*", + `Comma-separated list of controllers to enable. Use "*" to enable all. +Valid values: `+strings.Join(knownControllers, ", ")) +``` + +- [ ] **Step 2: Parse the flag and gate controllers** + +After `flag.Parse()` and `ctrl.SetLogger(...)`, add: + +```go +enabled, err := parseControllers(controllersFlag) +if err != nil { + setupLog.Error(err, "invalid --controllers flag") + os.Exit(1) +} +``` + +- [ ] **Step 3: Gate the namespace controller block** + +Replace lines 281–329 (from `nsReconciler := ...` to the end of `InitMetrics` runnable) with: + +```go +if enabled(controller.NamespaceControllerName) { + var valkeyClient valkey.Client + switch { + case valkeyURL != "": + valkeyClient = valkey.NewDirectClient(valkeyURL, valkeyAdminPassword) + defer func() { _ = valkeyClient.Close() }() + setupLog.Info("Valkey ACL provisioning enabled (direct)", "addr", valkeyURL) + case valkeySentinelURLs != "": + valkeyClient = valkey.NewSentinelClient(valkey.Config{ + SentinelAddrs: strings.Split(valkeySentinelURLs, ","), + MasterName: valkeySentinelMaster, + AdminPassword: valkeyAdminPassword, + }) + defer func() { _ = valkeyClient.Close() }() + setupLog.Info("Valkey ACL provisioning enabled (sentinel)", + "sentinel", valkeySentinelURLs, "master", valkeySentinelMaster) + default: + valkeyClient = valkey.NoopClient{} + setupLog.Info("Valkey ACL provisioning disabled (set VALKEY_URL or VALKEY_SENTINEL_URLS)") + } + + nsReconciler := &controller.NamespaceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + ValkeyClient: valkeyClient, + ResyncPeriod: valkeyResyncPeriod, + } + if err := nsReconciler.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Namespace") + os.Exit(1) + } + if valkeyWatchFailover && valkeySentinelURLs != "" { + if err := mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { + return valkeyClient.WatchFailover(ctx, func() { + controller.RecordSentinelFailover() + nsReconciler.TriggerResyncAll(ctx) + }) + }}); err != nil { + setupLog.Error(err, "unable to add Sentinel failover watcher (non-fatal)") + } else { + setupLog.Info("Sentinel failover watcher enabled") + } + if err := mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { + return valkeyClient.WatchNodeRestart(ctx, func(addr string) { + nsReconciler.ProvisionSingleNode(ctx, addr) + }) + }}); err != nil { + setupLog.Error(err, "unable to add node-restart watcher (non-fatal)") + } else { + setupLog.Info("Sentinel node-restart watcher enabled") + } + } + if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + if !mgr.GetCache().WaitForCacheSync(ctx) { + return fmt.Errorf("cache never synced") + } + return nsReconciler.InitMetrics(ctx) + })); err != nil { + setupLog.Error(err, "unable to add metrics init runnable") + os.Exit(1) + } +} +``` + +- [ ] **Step 4: Gate the decofile controller block** + +Replace lines 331–352 (from `httpClient := ...` to end of webhooks block) with: + +```go +if enabled(controller.DecofileControllerName) { + httpClient := controller.NewHTTPClient() + if err := (&controller.DecofileReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + HTTPClient: httpClient, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Decofile") + os.Exit(1) + } + // nolint:goconst + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err := webhookv1.SetupServiceWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Service") + os.Exit(1) + } + if err := webhookv1.SetupDecofileWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Decofile") + os.Exit(1) + } + } +} +``` + +- [ ] **Step 5: Gate the deco controller block** + +Replace lines 353–368 (from `registry := ...` to DecoReconciler `SetupWithManager`) with: + +```go +if enabled(controller.DecoControllerName) { + registry := build.NewBuilderRegistry() + registry.Register("cloudflare-worker", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) + builderSAAnnotations := map[string]string{} + if roleArn := os.Getenv("BUILD_ROLE_ARN"); roleArn != "" { + builderSAAnnotations["eks.amazonaws.com/role-arn"] = roleArn + } + if err := (&controller.DecoReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Builder: registry, + BuilderSAAnnotations: builderSAAnnotations, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Deco") + os.Exit(1) + } +} +``` + +- [ ] **Step 6: Gate the decoredirect controller block** + +Replace lines 369–377 with: + +```go +if enabled(controller.DecoRedirectControllerName) { + if err := (&controller.DecoRedirectReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + IngressClass: redirectIngressClass, + ClusterIssuer: redirectClusterIssuer, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "DecoRedirect") + os.Exit(1) + } +} +``` + +- [ ] **Step 7: Gate the operator-api block** + +Replace lines 378–387 with: + +```go +if enabled(api.ControllerName) { + apiUser := os.Getenv("OPERATOR_API_USER") + apiPass := os.Getenv("OPERATOR_API_PASSWORD") + if apiUser != "" && apiPass != "" { + h := api.NewHandlers(mgr.GetClient(), redirectNamespace) + if err := mgr.Add(api.NewServer(operatorAPIAddr, apiUser, apiPass, h)); err != nil { + setupLog.Error(err, "unable to add operator API server") + os.Exit(1) + } + setupLog.Info("Operator API enabled", "addr", operatorAPIAddr) + } +} +``` + +- [ ] **Step 8: Remove the old `valkeyClient` block (now moved inside namespace gate)** + +Delete lines 259–279 (the original `var valkeyClient valkey.Client` switch block) since it's now inside the namespace gate. + +- [ ] **Step 9: Build and run tests** + +```bash +cd /Users/igoramf/projects/deco/operator +make test 2>&1 | tail -10 +``` + +Expected: all tests pass. + +- [ ] **Step 10: Commit** + +```bash +git add cmd/main.go +git commit -m "feat(controllers): gate each controller behind --controllers flag" +``` + +--- + +## Task 4: Helm chart — `controllers.enabled` value + deployment arg + +**Files:** +- Modify: `chart/values.yaml` +- Modify: `hack/helm-generator/main.go` + +- [ ] **Step 1: Add `controllers` block to `chart/values.yaml`** + +Add after the `podLabels: {}` block (around line 98), before the `cfworkers:` block: + +```yaml +# Controllers to enable at startup. +# Use ["*"] to enable all (default — preserves existing behavior). +# Valid values: namespace, decofile, deco, decoredirect, operator-api +controllers: + enabled: + - "*" +``` + +- [ ] **Step 2: Add `addControllersArg` to `hack/helm-generator/main.go`** + +Add this function after `addRedirectControllerArgs`: + +```go +func addControllersArg(templatesDir string) error { + files, err := filepath.Glob(filepath.Join(templatesDir, "deployment-*.yaml")) + if err != nil || len(files) == 0 { + return fmt.Errorf("no deployment file found") + } + deploymentFile := files[0] + content, err := os.ReadFile(deploymentFile) + if err != nil { + return err + } + arg := ` {{- if not (has "*" .Values.controllers.enabled) }} + - --controllers={{ join "," .Values.controllers.enabled }} + {{- end }}` + anchor := ` - --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs` + if !strings.Contains(string(content), anchor) { + return fmt.Errorf("anchor %q not found in %s", anchor, deploymentFile) + } + contentStr := strings.Replace(string(content), anchor, anchor+"\n"+arg, 1) + return os.WriteFile(deploymentFile, []byte(contentStr), 0644) +} +``` + +- [ ] **Step 3: Call `addControllersArg` in `main()`** + +In `hack/helm-generator/main.go`, after the `addRedirectControllerArgs` call, add: + +```go +if err := addControllersArg(templatesDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Could not add controllers arg: %v\n", err) +} +``` + +- [ ] **Step 4: Regenerate chart** + +```bash +cd /Users/igoramf/projects/deco/operator +make generate 2>&1 | tail -5 +``` + +Expected: exits 0. + +- [ ] **Step 5: Verify default renders no `--controllers` flag** + +```bash +helm template test chart/ 2>&1 | grep "\-\-controllers" +``` + +Expected: no output (default `["*"]` → flag not injected). + +- [ ] **Step 6: Verify explicit list injects the flag** + +```bash +helm template test chart/ \ + --set 'controllers.enabled[0]=decoredirect' \ + --set 'controllers.enabled[1]=operator-api' \ + 2>&1 | grep "\-\-controllers" +``` + +Expected: +``` + - --controllers=decoredirect,operator-api +``` + +- [ ] **Step 7: Lint** + +```bash +helm lint chart/ +``` + +Expected: `1 chart(s) linted, 0 chart(s) failed` + +- [ ] **Step 8: Commit** + +```bash +git add chart/values.yaml hack/helm-generator/main.go chart/templates/deployment-operator-controller-manager.yaml +git commit -m "feat(chart): add controllers.enabled value and --controllers arg injection" +``` + +--- + +## Final check + +- [ ] **Run full test suite** + +```bash +cd /Users/igoramf/projects/deco/operator +make test 2>&1 | tail -10 +``` + +Expected: all tests pass. + +- [ ] **Verify branch log** + +```bash +git log --oneline main..HEAD +``` + +Expected (4 commits): +``` + feat(chart): add controllers.enabled value and --controllers arg injection + feat(controllers): gate each controller behind --controllers flag + feat(controllers): add parseControllers with knownControllers registry + feat(controllers): add ControllerName constants to each controller +``` From 31472781e19a418a20bd643d4ca1fcd5f0949920 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 18:52:33 -0300 Subject: [PATCH 03/13] feat(controllers): add ControllerName constants to each controller --- internal/api/server.go | 2 ++ internal/controller/deco_controller.go | 7 ++++--- internal/controller/decofile_controller.go | 5 ++++- internal/controller/decoredirect_controller.go | 1 + internal/controller/namespace_controller.go | 11 ++++++----- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 5ef5cdf..05ba842 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -7,6 +7,8 @@ import ( "time" ) +const ControllerName = "operator-api" + // Server exposes a minimal HTTP API for managing operator resources. // It implements manager.Runnable so controller-runtime starts it alongside controllers. // TLS is terminated at the ingress/NLB layer; the server listens on plain HTTP. diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 7a825fa..321a4e7 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -22,9 +22,10 @@ import ( ) const ( - phaseRunning = "Running" - phaseSucceeded = "Succeeded" - phaseFailed = "Failed" + phaseRunning = "Running" + phaseSucceeded = "Succeeded" + phaseFailed = "Failed" + DecoControllerName = "deco" ) // DecoReconciler reconciles Deco objects. diff --git a/internal/controller/decofile_controller.go b/internal/controller/decofile_controller.go index ca7162d..dc4108f 100644 --- a/internal/controller/decofile_controller.go +++ b/internal/controller/decofile_controller.go @@ -44,7 +44,10 @@ import ( decositesv1alpha1 "github.com/deco-sites/decofile-operator/api/v1alpha1" ) -const condTypePodsNotified = "PodsNotified" +const ( + condTypePodsNotified = "PodsNotified" + DecofileControllerName = "decofile" +) // deploymentIdLabel is declared in notifier.go (same package). diff --git a/internal/controller/decoredirect_controller.go b/internal/controller/decoredirect_controller.go index d6268d6..dfc8448 100644 --- a/internal/controller/decoredirect_controller.go +++ b/internal/controller/decoredirect_controller.go @@ -34,6 +34,7 @@ type DecoRedirectReconciler struct { // dummyBackendName satisfies the k8s Ingress API requirement for a backend on every path. // nginx never routes to it because permanent-redirect intercepts first. const dummyBackendName = "redirect-dummy-backend" +const DecoRedirectControllerName = "decoredirect" // +kubebuilder:rbac:groups=deco.sites,resources=decoredict,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=deco.sites,resources=decoredict/status,verbs=get;update;patch diff --git a/internal/controller/namespace_controller.go b/internal/controller/namespace_controller.go index 48a52af..ca748de 100644 --- a/internal/controller/namespace_controller.go +++ b/internal/controller/namespace_controller.go @@ -41,11 +41,12 @@ import ( ) const ( - valkeyACLAnnotation = "deco.sites/valkey-acl" - valkeyACLFinalizer = "deco.sites/valkey-acl" - valkeySecretName = "valkey-acl" - siteNamespacePrefix = "sites-" - valkeyReservedDefault = "default" + valkeyACLAnnotation = "deco.sites/valkey-acl" + valkeyACLFinalizer = "deco.sites/valkey-acl" + valkeySecretName = "valkey-acl" + siteNamespacePrefix = "sites-" + valkeyReservedDefault = "default" + NamespaceControllerName = "namespace" ) // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;update;patch From 9247a14a647b2c30e1d7e9633e20ddb82ad0b7be Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 18:54:23 -0300 Subject: [PATCH 04/13] feat(controllers): add parseControllers with knownControllers registry Co-Authored-By: Claude Sonnet 4.6 --- cmd/controllers.go | 38 ++++++++++++++++++++++ cmd/controllers_test.go | 72 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 cmd/controllers.go create mode 100644 cmd/controllers_test.go diff --git a/cmd/controllers.go b/cmd/controllers.go new file mode 100644 index 0000000..3abe3c6 --- /dev/null +++ b/cmd/controllers.go @@ -0,0 +1,38 @@ +package main + +import ( + "fmt" + "slices" + "strings" + + "github.com/deco-sites/decofile-operator/internal/api" + "github.com/deco-sites/decofile-operator/internal/controller" +) + +var knownControllers = []string{ + controller.NamespaceControllerName, + controller.DecofileControllerName, + controller.DecoControllerName, + controller.DecoRedirectControllerName, + api.ControllerName, +} + +// parseControllers parses a comma-separated list of controller names. +// "*" enables all known controllers. +// Returns an error if any name is not in knownControllers. +func parseControllers(flag string) (func(string) bool, error) { + if strings.TrimSpace(flag) == "*" { + return func(string) bool { return true }, nil + } + parts := strings.Split(flag, ",") + set := make(map[string]bool, len(parts)) + for _, name := range parts { + name = strings.TrimSpace(name) + if !slices.Contains(knownControllers, name) { + return nil, fmt.Errorf("unknown controller %q; valid values: %s", + name, strings.Join(knownControllers, ", ")) + } + set[name] = true + } + return func(name string) bool { return set[name] }, nil +} diff --git a/cmd/controllers_test.go b/cmd/controllers_test.go new file mode 100644 index 0000000..d1faa33 --- /dev/null +++ b/cmd/controllers_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "strings" + "testing" + + "github.com/deco-sites/decofile-operator/internal/api" + "github.com/deco-sites/decofile-operator/internal/controller" +) + +func TestParseControllers_Star(t *testing.T) { + enabled, err := parseControllers("*") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for _, name := range []string{ + controller.NamespaceControllerName, + controller.DecofileControllerName, + controller.DecoControllerName, + controller.DecoRedirectControllerName, + api.ControllerName, + } { + if !enabled(name) { + t.Errorf("expected %q to be enabled with *, but it wasn't", name) + } + } +} + +func TestParseControllers_Subset(t *testing.T) { + enabled, err := parseControllers("decoredirect,operator-api") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !enabled(controller.DecoRedirectControllerName) { + t.Error("expected decoredirect to be enabled") + } + if !enabled(api.ControllerName) { + t.Error("expected operator-api to be enabled") + } + if enabled(controller.DecofileControllerName) { + t.Error("expected decofile to be disabled") + } + if enabled(controller.NamespaceControllerName) { + t.Error("expected namespace to be disabled") + } + if enabled(controller.DecoControllerName) { + t.Error("expected deco to be disabled") + } +} + +func TestParseControllers_UnknownName(t *testing.T) { + _, err := parseControllers("decoredirect,xpto") + if err == nil { + t.Fatal("expected error for unknown controller name, got nil") + } + if !strings.Contains(err.Error(), "xpto") { + t.Errorf("expected error to mention 'xpto', got: %v", err) + } +} + +func TestParseControllers_WhitespaceHandled(t *testing.T) { + enabled, err := parseControllers("decoredirect, operator-api") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !enabled(controller.DecoRedirectControllerName) { + t.Error("expected decoredirect to be enabled") + } + if !enabled(api.ControllerName) { + t.Error("expected operator-api to be enabled") + } +} From b7f6239ffd597cd3aba7d1d184a4f663ea3373e6 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 18:56:22 -0300 Subject: [PATCH 05/13] fix(controllers): rename flag param, add empty-string guard, improve star test --- cmd/controllers.go | 10 +++++++--- cmd/controllers_test.go | 8 +------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/cmd/controllers.go b/cmd/controllers.go index 3abe3c6..b9ea88d 100644 --- a/cmd/controllers.go +++ b/cmd/controllers.go @@ -20,14 +20,18 @@ var knownControllers = []string{ // parseControllers parses a comma-separated list of controller names. // "*" enables all known controllers. // Returns an error if any name is not in knownControllers. -func parseControllers(flag string) (func(string) bool, error) { - if strings.TrimSpace(flag) == "*" { +func parseControllers(input string) (func(string) bool, error) { + if strings.TrimSpace(input) == "*" { return func(string) bool { return true }, nil } - parts := strings.Split(flag, ",") + parts := strings.Split(input, ",") set := make(map[string]bool, len(parts)) for _, name := range parts { name = strings.TrimSpace(name) + if name == "" { + return nil, fmt.Errorf("controller name must not be empty; valid values: %s", + strings.Join(knownControllers, ", ")) + } if !slices.Contains(knownControllers, name) { return nil, fmt.Errorf("unknown controller %q; valid values: %s", name, strings.Join(knownControllers, ", ")) diff --git a/cmd/controllers_test.go b/cmd/controllers_test.go index d1faa33..74c13d8 100644 --- a/cmd/controllers_test.go +++ b/cmd/controllers_test.go @@ -13,13 +13,7 @@ func TestParseControllers_Star(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - for _, name := range []string{ - controller.NamespaceControllerName, - controller.DecofileControllerName, - controller.DecoControllerName, - controller.DecoRedirectControllerName, - api.ControllerName, - } { + for _, name := range knownControllers { if !enabled(name) { t.Errorf("expected %q to be enabled with *, but it wasn't", name) } From 58b9abbaede694e3f04763c5230443dd6c6891df Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 18:59:05 -0300 Subject: [PATCH 06/13] feat(controllers): gate each controller behind --controllers flag Adds --controllers flag to cmd/main.go that accepts a comma-separated list of controller names (or "*" for all). Each controller block is wrapped in an enabled(...) guard, and valkeyClient creation is moved inside the namespace controller gate. Co-Authored-By: Claude Sonnet 4.6 --- cmd/main.go | 233 +++++++++++++++++++++++++++------------------------- 1 file changed, 121 insertions(+), 112 deletions(-) diff --git a/cmd/main.go b/cmd/main.go index 06e122b..f372aab 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -135,6 +135,10 @@ func main() { flag.StringVar(&redirectClusterIssuer, "redirect-cluster-issuer", getEnvOrDefault("REDIRECT_CLUSTER_ISSUER", "letsencrypt"), "cert-manager ClusterIssuer name (matches redirect.clusterIssuer.name in values).") + var controllersFlag string + flag.StringVar(&controllersFlag, "controllers", "*", + "Comma-separated list of controllers to enable. Use \"*\" to enable all. Valid values: "+ + strings.Join(knownControllers, ", ")) opts := zap.Options{ Development: false, } @@ -143,6 +147,12 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + enabled, err := parseControllers(controllersFlag) + if err != nil { + setupLog.Error(err, "invalid --controllers flag") + os.Exit(1) + } + // if the enable-http2 flag is false (the default), http/2 should be disabled // due to its vulnerabilities. More specifically, disabling http/2 will // prevent from being vulnerable to the HTTP/2 Stream Cancellation and @@ -256,134 +266,133 @@ func main() { os.Exit(1) } - // Build Valkey client. Falls back to a no-op client when Sentinel URLs are not configured - // so the operator works in environments where Valkey ACL provisioning is not needed. - var valkeyClient valkey.Client - switch { - case valkeyURL != "": - valkeyClient = valkey.NewDirectClient(valkeyURL, valkeyAdminPassword) - defer func() { _ = valkeyClient.Close() }() - setupLog.Info("Valkey ACL provisioning enabled (direct)", "addr", valkeyURL) - case valkeySentinelURLs != "": - valkeyClient = valkey.NewSentinelClient(valkey.Config{ - SentinelAddrs: strings.Split(valkeySentinelURLs, ","), - MasterName: valkeySentinelMaster, - AdminPassword: valkeyAdminPassword, - }) - defer func() { _ = valkeyClient.Close() }() - setupLog.Info("Valkey ACL provisioning enabled (sentinel)", - "sentinel", valkeySentinelURLs, "master", valkeySentinelMaster) - default: - valkeyClient = valkey.NoopClient{} - setupLog.Info("Valkey ACL provisioning disabled (set VALKEY_URL or VALKEY_SENTINEL_URLS)") - } - - nsReconciler := &controller.NamespaceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - ValkeyClient: valkeyClient, - ResyncPeriod: valkeyResyncPeriod, - } - if err := nsReconciler.SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Namespace") - os.Exit(1) - } - // Start Sentinel failover watcher if enabled and Sentinel is configured. - // leaderElectedRunnable ensures only the active leader subscribes — prevents - // redundant TriggerResyncAll calls from non-leader replicas. - // Fail-safe: if subscription fails, operator continues with periodic resync. - if valkeyWatchFailover && valkeySentinelURLs != "" { - if err := mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { - return valkeyClient.WatchFailover(ctx, func() { - controller.RecordSentinelFailover() - nsReconciler.TriggerResyncAll(ctx) + if enabled(controller.NamespaceControllerName) { + var valkeyClient valkey.Client + switch { + case valkeyURL != "": + valkeyClient = valkey.NewDirectClient(valkeyURL, valkeyAdminPassword) + defer func() { _ = valkeyClient.Close() }() + setupLog.Info("Valkey ACL provisioning enabled (direct)", "addr", valkeyURL) + case valkeySentinelURLs != "": + valkeyClient = valkey.NewSentinelClient(valkey.Config{ + SentinelAddrs: strings.Split(valkeySentinelURLs, ","), + MasterName: valkeySentinelMaster, + AdminPassword: valkeyAdminPassword, }) - }}); err != nil { - setupLog.Error(err, "unable to add Sentinel failover watcher (non-fatal)") - } else { - setupLog.Info("Sentinel failover watcher enabled") + defer func() { _ = valkeyClient.Close() }() + setupLog.Info("Valkey ACL provisioning enabled (sentinel)", + "sentinel", valkeySentinelURLs, "master", valkeySentinelMaster) + default: + valkeyClient = valkey.NoopClient{} + setupLog.Info("Valkey ACL provisioning disabled (set VALKEY_URL or VALKEY_SENTINEL_URLS)") } - // Watch for replica restarts (+reboot/-sdown) to re-provision only the - // affected node immediately, without waiting for the periodic resync cycle. - if err := mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { - return valkeyClient.WatchNodeRestart(ctx, func(addr string) { - nsReconciler.ProvisionSingleNode(ctx, addr) - }) - }}); err != nil { - setupLog.Error(err, "unable to add node-restart watcher (non-fatal)") - } else { - setupLog.Info("Sentinel node-restart watcher enabled") + nsReconciler := &controller.NamespaceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + ValkeyClient: valkeyClient, + ResyncPeriod: valkeyResyncPeriod, + } + if err = nsReconciler.SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Namespace") + os.Exit(1) + } + if valkeyWatchFailover && valkeySentinelURLs != "" { + if err = mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { + return valkeyClient.WatchFailover(ctx, func() { + controller.RecordSentinelFailover() + nsReconciler.TriggerResyncAll(ctx) + }) + }}); err != nil { + setupLog.Error(err, "unable to add Sentinel failover watcher (non-fatal)") + } else { + setupLog.Info("Sentinel failover watcher enabled") + } + if err = mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { + return valkeyClient.WatchNodeRestart(ctx, func(addr string) { + nsReconciler.ProvisionSingleNode(ctx, addr) + }) + }}); err != nil { + setupLog.Error(err, "unable to add node-restart watcher (non-fatal)") + } else { + setupLog.Info("Sentinel node-restart watcher enabled") + } + } + if err = mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + if !mgr.GetCache().WaitForCacheSync(ctx) { + return fmt.Errorf("cache never synced") + } + return nsReconciler.InitMetrics(ctx) + })); err != nil { + setupLog.Error(err, "unable to add metrics init runnable") + os.Exit(1) } } - // Seed the tenants_provisioned gauge from current cluster state once the cache is warm. - if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { - if !mgr.GetCache().WaitForCacheSync(ctx) { - return fmt.Errorf("cache never synced") + if enabled(controller.DecofileControllerName) { + httpClient := controller.NewHTTPClient() + if err = (&controller.DecofileReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + HTTPClient: httpClient, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Decofile") + os.Exit(1) + } + // nolint:goconst + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err = webhookv1.SetupServiceWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Service") + os.Exit(1) + } + if err = webhookv1.SetupDecofileWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Decofile") + os.Exit(1) + } } - return nsReconciler.InitMetrics(ctx) - })); err != nil { - setupLog.Error(err, "unable to add metrics init runnable") - os.Exit(1) } - // Create shared HTTP client for pod notifications to prevent connection leaks - httpClient := controller.NewHTTPClient() - - if err := (&controller.DecofileReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - HTTPClient: httpClient, - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Decofile") - os.Exit(1) - } - // nolint:goconst - if os.Getenv("ENABLE_WEBHOOKS") != "false" { - if err := webhookv1.SetupServiceWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "Service") - os.Exit(1) + if enabled(controller.DecoControllerName) { + registry := build.NewBuilderRegistry() + registry.Register("cloudflare-worker", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) + builderSAAnnotations := map[string]string{} + if roleArn := os.Getenv("BUILD_ROLE_ARN"); roleArn != "" { + builderSAAnnotations["eks.amazonaws.com/role-arn"] = roleArn } - if err := webhookv1.SetupDecofileWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "Decofile") + if err = (&controller.DecoReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Builder: registry, + BuilderSAAnnotations: builderSAAnnotations, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Deco") os.Exit(1) } } - registry := build.NewBuilderRegistry() - registry.Register("cloudflare-worker", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) - builderSAAnnotations := map[string]string{} - if roleArn := os.Getenv("BUILD_ROLE_ARN"); roleArn != "" { - builderSAAnnotations["eks.amazonaws.com/role-arn"] = roleArn - } - if err := (&controller.DecoReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Builder: registry, - BuilderSAAnnotations: builderSAAnnotations, - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Deco") - os.Exit(1) - } - if err := (&controller.DecoRedirectReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - IngressClass: redirectIngressClass, - ClusterIssuer: redirectClusterIssuer, - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "DecoRedirect") - os.Exit(1) - } - apiUser := os.Getenv("OPERATOR_API_USER") - apiPass := os.Getenv("OPERATOR_API_PASSWORD") - if apiUser != "" && apiPass != "" { - h := api.NewHandlers(mgr.GetClient(), redirectNamespace) - if err := mgr.Add(api.NewServer(operatorAPIAddr, apiUser, apiPass, h)); err != nil { - setupLog.Error(err, "unable to add operator API server") + if enabled(controller.DecoRedirectControllerName) { + if err = (&controller.DecoRedirectReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + IngressClass: redirectIngressClass, + ClusterIssuer: redirectClusterIssuer, + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "DecoRedirect") os.Exit(1) } - setupLog.Info("Operator API enabled", "addr", operatorAPIAddr) + } + + if enabled(api.ControllerName) { + apiUser := os.Getenv("OPERATOR_API_USER") + apiPass := os.Getenv("OPERATOR_API_PASSWORD") + if apiUser != "" && apiPass != "" { + h := api.NewHandlers(mgr.GetClient(), redirectNamespace) + if err = mgr.Add(api.NewServer(operatorAPIAddr, apiUser, apiPass, h)); err != nil { + setupLog.Error(err, "unable to add operator API server") + os.Exit(1) + } + setupLog.Info("Operator API enabled", "addr", operatorAPIAddr) + } } // +kubebuilder:scaffold:builder From 5b850fe1f2f2f6c31d7dbfd9333a1a4e799e7e84 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 19:02:40 -0300 Subject: [PATCH 07/13] feat(chart): add controllers.enabled value and --controllers arg injection Co-Authored-By: Claude Sonnet 4.6 --- ...eployment-operator-controller-manager.yaml | 3 +++ chart/values.yaml | 7 ++++++ hack/helm-generator/main.go | 24 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index b83b91c..a1264fc 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -31,6 +31,9 @@ spec: - --leader-elect - --health-probe-bind-address=:8081 - --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs + {{- if not (has "*" .Values.controllers.enabled) }} + - --controllers={{ join "," .Values.controllers.enabled }} + {{- end }} {{- if .Values.redirect.ingressClass }} - --redirect-ingress-class={{ .Values.redirect.ingressClass }} - --redirect-cluster-issuer={{ .Values.redirect.clusterIssuer.name }} diff --git a/chart/values.yaml b/chart/values.yaml index 430556f..5cfad51 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -97,6 +97,13 @@ podAnnotations: {} # Pod labels podLabels: {} +# Controllers to enable at startup. +# Use ["*"] to enable all (default — preserves existing behavior). +# Valid values: namespace, decofile, deco, decoredirect, operator-api +controllers: + enabled: + - "*" + # Cloudflare Workers build support cfworkers: existingSecret: "" # Secret with cf-api-token, cf-account-id diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 97b4bff..13c7d4f 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -117,6 +117,9 @@ func main() { if err := addOperatorAPIIngress(templatesDir); err != nil { fmt.Fprintf(os.Stderr, "Warning: Could not add redirect API ingress: %v\n", err) } + if err := addControllersArg(templatesDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: Could not add controllers arg: %v\n", err) + } fmt.Printf("✓ Generated %d Helm templates\n\n", fileCount) fmt.Println("Test with:") @@ -424,6 +427,27 @@ func addRedirectControllerArgs(templatesDir string) error { return os.WriteFile(deploymentFile, []byte(contentStr), 0644) } +func addControllersArg(templatesDir string) error { + files, err := filepath.Glob(filepath.Join(templatesDir, "deployment-*.yaml")) + if err != nil || len(files) == 0 { + return fmt.Errorf("no deployment file found") + } + deploymentFile := files[0] + content, err := os.ReadFile(deploymentFile) + if err != nil { + return err + } + arg := ` {{- if not (has "*" .Values.controllers.enabled) }} + - --controllers={{ join "," .Values.controllers.enabled }} + {{- end }}` + anchor := ` - --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs` + if !strings.Contains(string(content), anchor) { + return fmt.Errorf("anchor %q not found in %s", anchor, deploymentFile) + } + contentStr := strings.Replace(string(content), anchor, anchor+"\n"+arg, 1) + return os.WriteFile(deploymentFile, []byte(contentStr), 0644) +} + func addOperatorAPIEnvVars(templatesDir string) error { files, err := filepath.Glob(filepath.Join(templatesDir, "deployment-*.yaml")) if err != nil || len(files) == 0 { From 27d40806ec72ab669ae63739de445bb3a370e754 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 19:04:55 -0300 Subject: [PATCH 08/13] fix(chart): guard against empty controllers.enabled list --- chart/templates/deployment-operator-controller-manager.yaml | 2 +- hack/helm-generator/main.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/chart/templates/deployment-operator-controller-manager.yaml b/chart/templates/deployment-operator-controller-manager.yaml index a1264fc..e3d51af 100644 --- a/chart/templates/deployment-operator-controller-manager.yaml +++ b/chart/templates/deployment-operator-controller-manager.yaml @@ -31,7 +31,7 @@ spec: - --leader-elect - --health-probe-bind-address=:8081 - --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs - {{- if not (has "*" .Values.controllers.enabled) }} + {{- if and .Values.controllers.enabled (not (has "*" .Values.controllers.enabled)) }} - --controllers={{ join "," .Values.controllers.enabled }} {{- end }} {{- if .Values.redirect.ingressClass }} diff --git a/hack/helm-generator/main.go b/hack/helm-generator/main.go index 13c7d4f..c97b0d5 100644 --- a/hack/helm-generator/main.go +++ b/hack/helm-generator/main.go @@ -437,7 +437,7 @@ func addControllersArg(templatesDir string) error { if err != nil { return err } - arg := ` {{- if not (has "*" .Values.controllers.enabled) }} + arg := ` {{- if and .Values.controllers.enabled (not (has "*" .Values.controllers.enabled)) }} - --controllers={{ join "," .Values.controllers.enabled }} {{- end }}` anchor := ` - --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs` From 33c6865bf50e250541973fb2bce039bff74a60cf Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 19:10:17 -0300 Subject: [PATCH 09/13] fix(docker): copy full cmd/ dir and build ./cmd to include controllers.go --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index c6e92b2..dc16bc2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,7 +12,7 @@ COPY go.sum go.sum RUN go mod download # Copy the go source -COPY cmd/main.go cmd/main.go +COPY cmd/ cmd/ COPY api/ api/ COPY internal/ internal/ @@ -21,7 +21,7 @@ COPY internal/ internal/ # was called. For example, if we call make docker-build in a local env which has the Apple Silicon M1 SO # the docker BUILDPLATFORM arg will be linux/arm64 when for Apple x86 it will be linux/amd64. Therefore, # by leaving it empty we can ensure that the container and binary shipped on it will have the same platform. -RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager cmd/main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS:-linux} GOARCH=${TARGETARCH} go build -a -o manager ./cmd # Use distroless as minimal base image to package the manager binary # Refer to https://github.com/GoogleContainerTools/distroless for more details From 1ec080839cdcb45addbed317839627c9881a01b6 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 19:42:07 -0300 Subject: [PATCH 10/13] chore: remove superpowers docs --- .../2026-05-28-controller-feature-flags.md | 601 ------------------ ...6-05-28-controller-feature-flags-design.md | 184 ------ 2 files changed, 785 deletions(-) delete mode 100644 docs/superpowers/plans/2026-05-28-controller-feature-flags.md delete mode 100644 docs/superpowers/specs/2026-05-28-controller-feature-flags-design.md diff --git a/docs/superpowers/plans/2026-05-28-controller-feature-flags.md b/docs/superpowers/plans/2026-05-28-controller-feature-flags.md deleted file mode 100644 index d5c1ad6..0000000 --- a/docs/superpowers/plans/2026-05-28-controller-feature-flags.md +++ /dev/null @@ -1,601 +0,0 @@ -# Controller Feature Flags — 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 `--controllers` flag to the operator and a `controllers.enabled` Helm value so each cluster deployment can opt-in to only the controllers it needs. - -**Architecture:** Each controller defines a `ControllerName` string constant. A `parseControllers` helper in `cmd/controllers.go` parses the comma-separated flag (supporting `"*"` for all), validates names against a registry of known controllers, and returns an `enabled(name string) bool` function. `main.go` wraps each controller's setup with this check. The Helm chart injects `--controllers=` into the deployment args. - -**Tech Stack:** Go 1.24, controller-runtime, Helm 3, Sprig template functions (`join`). - ---- - -## File Map - -| File | Action | Purpose | -|------|--------|---------| -| `internal/controller/decoredirect_controller.go` | Modify | Add `ControllerName = "decoredirect"` | -| `internal/controller/decofile_controller.go` | Modify | Add `ControllerName = "decofile"` | -| `internal/controller/deco_controller.go` | Modify | Add `ControllerName = "deco"` | -| `internal/controller/namespace_controller.go` | Modify | Add `ControllerName = "namespace"` | -| `internal/api/server.go` | Modify | Add `ControllerName = "operator-api"` | -| `cmd/controllers.go` | Create | `parseControllers` function + `knownControllers` registry | -| `cmd/controllers_test.go` | Create | Unit tests for `parseControllers` | -| `cmd/main.go` | Modify | Add `--controllers` flag, call `parseControllers`, gate each controller | -| `chart/values.yaml` | Modify | Add `controllers.enabled: ["*"]` | -| `hack/helm-generator/main.go` | Modify | Inject `--controllers` arg into deployment | - ---- - -## Task 1: Add ControllerName constants - -**Files:** -- Modify: `internal/controller/decoredirect_controller.go` -- Modify: `internal/controller/decofile_controller.go` -- Modify: `internal/controller/deco_controller.go` -- Modify: `internal/controller/namespace_controller.go` -- Modify: `internal/api/server.go` - -All four controllers live in `package controller` — each constant needs a unique name to avoid conflicts. - -- [ ] **Step 1: Add constant to `decoredirect_controller.go`** - -After the existing `const dummyBackendName = "redirect-dummy-backend"` line, add: - -```go -const DecoRedirectControllerName = "decoredirect" -``` - -- [ ] **Step 2: Add constant to `decofile_controller.go`** - -Add to the existing `const` block at the top of the file: - -```go -const DecofileControllerName = "decofile" -``` - -- [ ] **Step 3: Add constant to `deco_controller.go`** - -Add to the existing `const` block: - -```go -const DecoControllerName = "deco" -``` - -- [ ] **Step 4: Add constant to `namespace_controller.go`** - -Add to the existing `const` block: - -```go -const NamespaceControllerName = "namespace" -``` - -- [ ] **Step 5: Add constant to `internal/api/server.go`** - -After the package declaration, add: - -```go -const ControllerName = "operator-api" -``` - -- [ ] **Step 6: Verify the code compiles** - -```bash -cd /Users/igoramf/projects/deco/operator -go build ./... -``` - -Expected: exits 0, no errors. - -- [ ] **Step 7: Commit** - -```bash -git add internal/controller/decoredirect_controller.go \ - internal/controller/decofile_controller.go \ - internal/controller/deco_controller.go \ - internal/controller/namespace_controller.go \ - internal/api/server.go -git commit -m "feat(controllers): add ControllerName constants to each controller" -``` - ---- - -## Task 2: `parseControllers` function with tests - -**Files:** -- Create: `cmd/controllers.go` -- Create: `cmd/controllers_test.go` - -- [ ] **Step 1: Write the failing tests** - -Create `cmd/controllers_test.go`: - -```go -package main - -import ( - "strings" - "testing" - - "github.com/deco-sites/decofile-operator/internal/api" - "github.com/deco-sites/decofile-operator/internal/controller" -) - -func TestParseControllers_Star(t *testing.T) { - enabled, err := parseControllers("*") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - for _, name := range []string{ - controller.NamespaceControllerName, - controller.DecofileControllerName, - controller.DecoControllerName, - controller.DecoRedirectControllerName, - api.ControllerName, - } { - if !enabled(name) { - t.Errorf("expected %q to be enabled with *, but it wasn't", name) - } - } -} - -func TestParseControllers_Subset(t *testing.T) { - enabled, err := parseControllers("decoredirect,operator-api") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !enabled(controller.DecoRedirectControllerName) { - t.Error("expected decoredirect to be enabled") - } - if !enabled(api.ControllerName) { - t.Error("expected operator-api to be enabled") - } - if enabled(controller.DecofileControllerName) { - t.Error("expected decofile to be disabled") - } - if enabled(controller.NamespaceControllerName) { - t.Error("expected namespace to be disabled") - } - if enabled(controller.DecoControllerName) { - t.Error("expected deco to be disabled") - } -} - -func TestParseControllers_UnknownName(t *testing.T) { - _, err := parseControllers("decoredirect,xpto") - if err == nil { - t.Fatal("expected error for unknown controller name, got nil") - } - if !strings.Contains(err.Error(), "xpto") { - t.Errorf("expected error to mention 'xpto', got: %v", err) - } -} - -func TestParseControllers_WhitespaceHandled(t *testing.T) { - enabled, err := parseControllers("decoredirect, operator-api") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !enabled(controller.DecoRedirectControllerName) { - t.Error("expected decoredirect to be enabled") - } - if !enabled(api.ControllerName) { - t.Error("expected operator-api to be enabled") - } -} -``` - -- [ ] **Step 2: Run tests — expect compile error** (function not defined yet) - -```bash -cd /Users/igoramf/projects/deco/operator -go test ./cmd/... 2>&1 | head -10 -``` - -Expected: compile error `undefined: parseControllers` - -- [ ] **Step 3: Create `cmd/controllers.go`** - -```go -package main - -import ( - "fmt" - "slices" - "strings" - - "github.com/deco-sites/decofile-operator/internal/api" - "github.com/deco-sites/decofile-operator/internal/controller" -) - -var knownControllers = []string{ - controller.NamespaceControllerName, - controller.DecofileControllerName, - controller.DecoControllerName, - controller.DecoRedirectControllerName, - api.ControllerName, -} - -// parseControllers parses a comma-separated list of controller names. -// "*" enables all known controllers. -// Returns an error if any name is not in knownControllers. -func parseControllers(flag string) (func(string) bool, error) { - if strings.TrimSpace(flag) == "*" { - return func(string) bool { return true }, nil - } - parts := strings.Split(flag, ",") - set := make(map[string]bool, len(parts)) - for _, name := range parts { - name = strings.TrimSpace(name) - if !slices.Contains(knownControllers, name) { - return nil, fmt.Errorf("unknown controller %q; valid values: %s", - name, strings.Join(knownControllers, ", ")) - } - set[name] = true - } - return func(name string) bool { return set[name] }, nil -} -``` - -- [ ] **Step 4: Run tests — expect all to pass** - -```bash -cd /Users/igoramf/projects/deco/operator -go test ./cmd/... -v 2>&1 | tail -15 -``` - -Expected: all 4 tests PASS. - -- [ ] **Step 5: Commit** - -```bash -git add cmd/controllers.go cmd/controllers_test.go -git commit -m "feat(controllers): add parseControllers with knownControllers registry" -``` - ---- - -## Task 3: Wire `--controllers` flag in `main.go` - -**Files:** -- Modify: `cmd/main.go` - -Note: `main.go` is restructured to scope each controller's variables inside its own `if enabled(...)` block. - -- [ ] **Step 1: Add the `--controllers` flag after `flag.StringVar(&redirectClusterIssuer, ...)` (around line 137)** - -Add this variable declaration at the top of `main()` with the other vars: - -```go -var controllersFlag string -``` - -Add this flag definition after the `redirectClusterIssuer` flag (before `opts.BindFlags`): - -```go -flag.StringVar(&controllersFlag, "controllers", "*", - `Comma-separated list of controllers to enable. Use "*" to enable all. -Valid values: `+strings.Join(knownControllers, ", ")) -``` - -- [ ] **Step 2: Parse the flag and gate controllers** - -After `flag.Parse()` and `ctrl.SetLogger(...)`, add: - -```go -enabled, err := parseControllers(controllersFlag) -if err != nil { - setupLog.Error(err, "invalid --controllers flag") - os.Exit(1) -} -``` - -- [ ] **Step 3: Gate the namespace controller block** - -Replace lines 281–329 (from `nsReconciler := ...` to the end of `InitMetrics` runnable) with: - -```go -if enabled(controller.NamespaceControllerName) { - var valkeyClient valkey.Client - switch { - case valkeyURL != "": - valkeyClient = valkey.NewDirectClient(valkeyURL, valkeyAdminPassword) - defer func() { _ = valkeyClient.Close() }() - setupLog.Info("Valkey ACL provisioning enabled (direct)", "addr", valkeyURL) - case valkeySentinelURLs != "": - valkeyClient = valkey.NewSentinelClient(valkey.Config{ - SentinelAddrs: strings.Split(valkeySentinelURLs, ","), - MasterName: valkeySentinelMaster, - AdminPassword: valkeyAdminPassword, - }) - defer func() { _ = valkeyClient.Close() }() - setupLog.Info("Valkey ACL provisioning enabled (sentinel)", - "sentinel", valkeySentinelURLs, "master", valkeySentinelMaster) - default: - valkeyClient = valkey.NoopClient{} - setupLog.Info("Valkey ACL provisioning disabled (set VALKEY_URL or VALKEY_SENTINEL_URLS)") - } - - nsReconciler := &controller.NamespaceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - ValkeyClient: valkeyClient, - ResyncPeriod: valkeyResyncPeriod, - } - if err := nsReconciler.SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Namespace") - os.Exit(1) - } - if valkeyWatchFailover && valkeySentinelURLs != "" { - if err := mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { - return valkeyClient.WatchFailover(ctx, func() { - controller.RecordSentinelFailover() - nsReconciler.TriggerResyncAll(ctx) - }) - }}); err != nil { - setupLog.Error(err, "unable to add Sentinel failover watcher (non-fatal)") - } else { - setupLog.Info("Sentinel failover watcher enabled") - } - if err := mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { - return valkeyClient.WatchNodeRestart(ctx, func(addr string) { - nsReconciler.ProvisionSingleNode(ctx, addr) - }) - }}); err != nil { - setupLog.Error(err, "unable to add node-restart watcher (non-fatal)") - } else { - setupLog.Info("Sentinel node-restart watcher enabled") - } - } - if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { - if !mgr.GetCache().WaitForCacheSync(ctx) { - return fmt.Errorf("cache never synced") - } - return nsReconciler.InitMetrics(ctx) - })); err != nil { - setupLog.Error(err, "unable to add metrics init runnable") - os.Exit(1) - } -} -``` - -- [ ] **Step 4: Gate the decofile controller block** - -Replace lines 331–352 (from `httpClient := ...` to end of webhooks block) with: - -```go -if enabled(controller.DecofileControllerName) { - httpClient := controller.NewHTTPClient() - if err := (&controller.DecofileReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - HTTPClient: httpClient, - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Decofile") - os.Exit(1) - } - // nolint:goconst - if os.Getenv("ENABLE_WEBHOOKS") != "false" { - if err := webhookv1.SetupServiceWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "Service") - os.Exit(1) - } - if err := webhookv1.SetupDecofileWebhookWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create webhook", "webhook", "Decofile") - os.Exit(1) - } - } -} -``` - -- [ ] **Step 5: Gate the deco controller block** - -Replace lines 353–368 (from `registry := ...` to DecoReconciler `SetupWithManager`) with: - -```go -if enabled(controller.DecoControllerName) { - registry := build.NewBuilderRegistry() - registry.Register("cloudflare-worker", build.NewCloudflareFactory(build.CfWorkersConfigFromEnv())) - builderSAAnnotations := map[string]string{} - if roleArn := os.Getenv("BUILD_ROLE_ARN"); roleArn != "" { - builderSAAnnotations["eks.amazonaws.com/role-arn"] = roleArn - } - if err := (&controller.DecoReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Builder: registry, - BuilderSAAnnotations: builderSAAnnotations, - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "Deco") - os.Exit(1) - } -} -``` - -- [ ] **Step 6: Gate the decoredirect controller block** - -Replace lines 369–377 with: - -```go -if enabled(controller.DecoRedirectControllerName) { - if err := (&controller.DecoRedirectReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - IngressClass: redirectIngressClass, - ClusterIssuer: redirectClusterIssuer, - }).SetupWithManager(mgr); err != nil { - setupLog.Error(err, "unable to create controller", "controller", "DecoRedirect") - os.Exit(1) - } -} -``` - -- [ ] **Step 7: Gate the operator-api block** - -Replace lines 378–387 with: - -```go -if enabled(api.ControllerName) { - apiUser := os.Getenv("OPERATOR_API_USER") - apiPass := os.Getenv("OPERATOR_API_PASSWORD") - if apiUser != "" && apiPass != "" { - h := api.NewHandlers(mgr.GetClient(), redirectNamespace) - if err := mgr.Add(api.NewServer(operatorAPIAddr, apiUser, apiPass, h)); err != nil { - setupLog.Error(err, "unable to add operator API server") - os.Exit(1) - } - setupLog.Info("Operator API enabled", "addr", operatorAPIAddr) - } -} -``` - -- [ ] **Step 8: Remove the old `valkeyClient` block (now moved inside namespace gate)** - -Delete lines 259–279 (the original `var valkeyClient valkey.Client` switch block) since it's now inside the namespace gate. - -- [ ] **Step 9: Build and run tests** - -```bash -cd /Users/igoramf/projects/deco/operator -make test 2>&1 | tail -10 -``` - -Expected: all tests pass. - -- [ ] **Step 10: Commit** - -```bash -git add cmd/main.go -git commit -m "feat(controllers): gate each controller behind --controllers flag" -``` - ---- - -## Task 4: Helm chart — `controllers.enabled` value + deployment arg - -**Files:** -- Modify: `chart/values.yaml` -- Modify: `hack/helm-generator/main.go` - -- [ ] **Step 1: Add `controllers` block to `chart/values.yaml`** - -Add after the `podLabels: {}` block (around line 98), before the `cfworkers:` block: - -```yaml -# Controllers to enable at startup. -# Use ["*"] to enable all (default — preserves existing behavior). -# Valid values: namespace, decofile, deco, decoredirect, operator-api -controllers: - enabled: - - "*" -``` - -- [ ] **Step 2: Add `addControllersArg` to `hack/helm-generator/main.go`** - -Add this function after `addRedirectControllerArgs`: - -```go -func addControllersArg(templatesDir string) error { - files, err := filepath.Glob(filepath.Join(templatesDir, "deployment-*.yaml")) - if err != nil || len(files) == 0 { - return fmt.Errorf("no deployment file found") - } - deploymentFile := files[0] - content, err := os.ReadFile(deploymentFile) - if err != nil { - return err - } - arg := ` {{- if not (has "*" .Values.controllers.enabled) }} - - --controllers={{ join "," .Values.controllers.enabled }} - {{- end }}` - anchor := ` - --webhook-cert-path=/tmp/k8s-webhook-server/serving-certs` - if !strings.Contains(string(content), anchor) { - return fmt.Errorf("anchor %q not found in %s", anchor, deploymentFile) - } - contentStr := strings.Replace(string(content), anchor, anchor+"\n"+arg, 1) - return os.WriteFile(deploymentFile, []byte(contentStr), 0644) -} -``` - -- [ ] **Step 3: Call `addControllersArg` in `main()`** - -In `hack/helm-generator/main.go`, after the `addRedirectControllerArgs` call, add: - -```go -if err := addControllersArg(templatesDir); err != nil { - fmt.Fprintf(os.Stderr, "Warning: Could not add controllers arg: %v\n", err) -} -``` - -- [ ] **Step 4: Regenerate chart** - -```bash -cd /Users/igoramf/projects/deco/operator -make generate 2>&1 | tail -5 -``` - -Expected: exits 0. - -- [ ] **Step 5: Verify default renders no `--controllers` flag** - -```bash -helm template test chart/ 2>&1 | grep "\-\-controllers" -``` - -Expected: no output (default `["*"]` → flag not injected). - -- [ ] **Step 6: Verify explicit list injects the flag** - -```bash -helm template test chart/ \ - --set 'controllers.enabled[0]=decoredirect' \ - --set 'controllers.enabled[1]=operator-api' \ - 2>&1 | grep "\-\-controllers" -``` - -Expected: -``` - - --controllers=decoredirect,operator-api -``` - -- [ ] **Step 7: Lint** - -```bash -helm lint chart/ -``` - -Expected: `1 chart(s) linted, 0 chart(s) failed` - -- [ ] **Step 8: Commit** - -```bash -git add chart/values.yaml hack/helm-generator/main.go chart/templates/deployment-operator-controller-manager.yaml -git commit -m "feat(chart): add controllers.enabled value and --controllers arg injection" -``` - ---- - -## Final check - -- [ ] **Run full test suite** - -```bash -cd /Users/igoramf/projects/deco/operator -make test 2>&1 | tail -10 -``` - -Expected: all tests pass. - -- [ ] **Verify branch log** - -```bash -git log --oneline main..HEAD -``` - -Expected (4 commits): -``` - feat(chart): add controllers.enabled value and --controllers arg injection - feat(controllers): gate each controller behind --controllers flag - feat(controllers): add parseControllers with knownControllers registry - feat(controllers): add ControllerName constants to each controller -``` diff --git a/docs/superpowers/specs/2026-05-28-controller-feature-flags-design.md b/docs/superpowers/specs/2026-05-28-controller-feature-flags-design.md deleted file mode 100644 index 9a60bb2..0000000 --- a/docs/superpowers/specs/2026-05-28-controller-feature-flags-design.md +++ /dev/null @@ -1,184 +0,0 @@ -# Controller Feature Flags — Design - -**Date:** 2026-05-28 -**Status:** Approved - ---- - -## Problem - -The operator bundles multiple independent controllers (Decofile, Deco builds, DecoRedirect, Namespace/Valkey, HTTP API). Not all clusters need all controllers — the hub cluster only needs `decoredirect` and `operator-api`, but today all controllers start unconditionally. - -This causes two problems: -1. Controllers that watch CRDs not installed in the cluster (e.g., Knative `Revision` in `DecofileReconciler`) crash the operator on startup. -2. There is no way to scope a deployment to only the features a cluster actually uses. - ---- - -## Goal - -Add a `controllers.enabled` list to the Helm values that controls which controllers are registered at startup. Default is `["*"]` (all controllers — preserves existing behavior). Unknown names are fatal errors. - ---- - -## Design - -### 1. Controller Name Constants - -Each controller defines a `ControllerName` string constant in its own file, co-located with the reconciler. This makes the name explicit, discoverable, and compiler-checked. - -```go -// internal/controller/decoredirect_controller.go -const ControllerName = "decoredirect" - -// internal/controller/decofile_controller.go -const ControllerName = "decofile" - -// internal/controller/deco_controller.go -const ControllerName = "deco" - -// internal/controller/namespace_controller.go -const ControllerName = "namespace" -``` - -For the HTTP API (not a controller-runtime reconciler): -```go -// internal/api/server.go -const ControllerName = "operator-api" -``` - -### 2. CLI Flag - -In `cmd/main.go`, add a `--controllers` flag: - -```go -var controllersFlag string -flag.StringVar(&controllersFlag, "controllers", "*", - `Comma-separated list of controllers to enable. Use "*" to enable all. -Valid values: namespace, decofile, deco, decoredirect, operator-api`) -``` - -### 3. Controller Set Logic - -A small helper in `cmd/main.go` (or a new `cmd/controllers.go`) parses the flag and returns an `enabled(name string) bool` function: - -```go -var knownControllers = []string{ - controller.NamespaceControllerName, - controller.DecofileControllerName, - controller.DecoControllerName, - controller.DecoRedirectControllerName, - api.ControllerName, -} - -func parseControllers(flag string) (func(string) bool, error) { - if flag == "*" { - return func(string) bool { return true }, nil - } - requested := strings.Split(flag, ",") - set := make(map[string]bool, len(requested)) - for _, name := range requested { - name = strings.TrimSpace(name) - if !slices.Contains(knownControllers, name) { - return nil, fmt.Errorf("unknown controller %q, valid values: %s", - name, strings.Join(knownControllers, ", ")) - } - set[name] = true - } - return func(name string) bool { return set[name] }, nil -} -``` - -Unknown name → returns error → `main` logs and calls `os.Exit(1)`. - -### 4. Gating Each Controller in `main.go` - -Each `SetupWithManager` / `mgr.Add` call is wrapped with the `enabled` check: - -```go -enabled, err := parseControllers(controllersFlag) -if err != nil { - setupLog.Error(err, "invalid --controllers flag") - os.Exit(1) -} - -if enabled(controller.NamespaceControllerName) { - if err := nsReconciler.SetupWithManager(mgr); err != nil { ... } -} - -if enabled(controller.DecofileControllerName) { - if err := (&controller.DecofileReconciler{...}).SetupWithManager(mgr); err != nil { ... } -} - -// etc. -``` - -### 5. Helm Chart - -**`values.yaml`** — new top-level block: - -```yaml -# Controllers to enable at startup. -# Use ["*"] to enable all (default — preserves existing behavior). -# Valid values: namespace, decofile, deco, decoredirect, operator-api -controllers: - enabled: - - "*" -``` - -**`deployment-operator-controller-manager.yaml`** (via helm-generator) — inject arg when not `["*"]`: - -```yaml -{{- if not (eq (index .Values.controllers.enabled 0) "*") }} -- --controllers={{ join "," .Values.controllers.enabled }} -{{- end }} -``` - -When `enabled: ["*"]`, no flag is injected and the default (`"*"`) applies. - ---- - -## Usage Examples - -**Hub cluster (`infra_applications/hub/values.yaml`):** -```yaml -controllers: - enabled: - - decoredirect - - operator-api -``` - -**Spoke cluster (all controllers):** -```yaml -controllers: - enabled: - - "*" # or omit entirely — same effect -``` - -**Unknown name (fatal at startup):** -``` -ERROR invalid --controllers flag {"error": "unknown controller \"xpto\", valid values: namespace, decofile, deco, decoredirect, operator-api"} -``` - ---- - -## Files to Change - -| File | Change | -|------|--------| -| `internal/controller/decoredirect_controller.go` | Add `ControllerName = "decoredirect"` | -| `internal/controller/decofile_controller.go` | Add `ControllerName = "decofile"` | -| `internal/controller/deco_controller.go` | Add `ControllerName = "deco"` | -| `internal/controller/namespace_controller.go` | Add `ControllerName = "namespace"` | -| `internal/api/server.go` | Add `ControllerName = "operator-api"` | -| `cmd/main.go` | Add `--controllers` flag + `parseControllers` + gate each controller | -| `chart/values.yaml` | Add `controllers.enabled: ["*"]` | -| `hack/helm-generator/main.go` | Inject `--controllers` arg in deployment when not `*` | - ---- - -## Out of Scope - -- Disabling individual sub-features within a controller (e.g., Knative watch inside Decofile) -- Dynamic reconfiguration at runtime (restart required to change active controllers) -- `--controllers=*,-decofile` exclusion syntax (list-only for now; can be added later) From b42838e72efc91d580df9aa28f0c0d2fbcb99521 Mon Sep 17 00:00:00 2001 From: igoramf Date: Thu, 28 May 2026 19:42:40 -0300 Subject: [PATCH 11/13] chore: fix gofmt alignment in controller const blocks --- internal/controller/deco_controller.go | 8 ++++---- internal/controller/decofile_controller.go | 4 ++-- internal/controller/namespace_controller.go | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/internal/controller/deco_controller.go b/internal/controller/deco_controller.go index 321a4e7..8bcb983 100644 --- a/internal/controller/deco_controller.go +++ b/internal/controller/deco_controller.go @@ -22,10 +22,10 @@ import ( ) const ( - phaseRunning = "Running" - phaseSucceeded = "Succeeded" - phaseFailed = "Failed" - DecoControllerName = "deco" + phaseRunning = "Running" + phaseSucceeded = "Succeeded" + phaseFailed = "Failed" + DecoControllerName = "deco" ) // DecoReconciler reconciles Deco objects. diff --git a/internal/controller/decofile_controller.go b/internal/controller/decofile_controller.go index dc4108f..ae73cb3 100644 --- a/internal/controller/decofile_controller.go +++ b/internal/controller/decofile_controller.go @@ -45,8 +45,8 @@ import ( ) const ( - condTypePodsNotified = "PodsNotified" - DecofileControllerName = "decofile" + condTypePodsNotified = "PodsNotified" + DecofileControllerName = "decofile" ) // deploymentIdLabel is declared in notifier.go (same package). diff --git a/internal/controller/namespace_controller.go b/internal/controller/namespace_controller.go index ca748de..1d70601 100644 --- a/internal/controller/namespace_controller.go +++ b/internal/controller/namespace_controller.go @@ -41,12 +41,12 @@ import ( ) const ( - valkeyACLAnnotation = "deco.sites/valkey-acl" - valkeyACLFinalizer = "deco.sites/valkey-acl" - valkeySecretName = "valkey-acl" - siteNamespacePrefix = "sites-" - valkeyReservedDefault = "default" - NamespaceControllerName = "namespace" + valkeyACLAnnotation = "deco.sites/valkey-acl" + valkeyACLFinalizer = "deco.sites/valkey-acl" + valkeySecretName = "valkey-acl" + siteNamespacePrefix = "sites-" + valkeyReservedDefault = "default" + NamespaceControllerName = "namespace" ) // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;update;patch From aa16b8d2b9043f0ba35120c748f9d9dc802f98c9 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 29 May 2026 10:44:24 -0300 Subject: [PATCH 12/13] refactor(controllers): rename NamespaceReconciler to TenantReconciler --- cmd/controllers.go | 2 +- cmd/controllers_test.go | 2 +- cmd/main.go | 12 ++++----- internal/controller/namespace_controller.go | 28 ++++++++++----------- 4 files changed, 22 insertions(+), 22 deletions(-) diff --git a/cmd/controllers.go b/cmd/controllers.go index b9ea88d..f9f1a32 100644 --- a/cmd/controllers.go +++ b/cmd/controllers.go @@ -10,7 +10,7 @@ import ( ) var knownControllers = []string{ - controller.NamespaceControllerName, + controller.TenantControllerName, controller.DecofileControllerName, controller.DecoControllerName, controller.DecoRedirectControllerName, diff --git a/cmd/controllers_test.go b/cmd/controllers_test.go index 74c13d8..6b1b545 100644 --- a/cmd/controllers_test.go +++ b/cmd/controllers_test.go @@ -34,7 +34,7 @@ func TestParseControllers_Subset(t *testing.T) { if enabled(controller.DecofileControllerName) { t.Error("expected decofile to be disabled") } - if enabled(controller.NamespaceControllerName) { + if enabled(controller.TenantControllerName) { t.Error("expected namespace to be disabled") } if enabled(controller.DecoControllerName) { diff --git a/cmd/main.go b/cmd/main.go index f372aab..a937366 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -266,7 +266,7 @@ func main() { os.Exit(1) } - if enabled(controller.NamespaceControllerName) { + if enabled(controller.TenantControllerName) { var valkeyClient valkey.Client switch { case valkeyURL != "": @@ -287,13 +287,13 @@ func main() { setupLog.Info("Valkey ACL provisioning disabled (set VALKEY_URL or VALKEY_SENTINEL_URLS)") } - nsReconciler := &controller.NamespaceReconciler{ + tenantReconciler := &controller.TenantReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), ValkeyClient: valkeyClient, ResyncPeriod: valkeyResyncPeriod, } - if err = nsReconciler.SetupWithManager(mgr); err != nil { + if err = tenantReconciler.SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "Namespace") os.Exit(1) } @@ -301,7 +301,7 @@ func main() { if err = mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { return valkeyClient.WatchFailover(ctx, func() { controller.RecordSentinelFailover() - nsReconciler.TriggerResyncAll(ctx) + tenantReconciler.TriggerResyncAll(ctx) }) }}); err != nil { setupLog.Error(err, "unable to add Sentinel failover watcher (non-fatal)") @@ -310,7 +310,7 @@ func main() { } if err = mgr.Add(&leaderElectedRunnable{fn: func(ctx context.Context) error { return valkeyClient.WatchNodeRestart(ctx, func(addr string) { - nsReconciler.ProvisionSingleNode(ctx, addr) + tenantReconciler.ProvisionSingleNode(ctx, addr) }) }}); err != nil { setupLog.Error(err, "unable to add node-restart watcher (non-fatal)") @@ -322,7 +322,7 @@ func main() { if !mgr.GetCache().WaitForCacheSync(ctx) { return fmt.Errorf("cache never synced") } - return nsReconciler.InitMetrics(ctx) + return tenantReconciler.InitMetrics(ctx) })); err != nil { setupLog.Error(err, "unable to add metrics init runnable") os.Exit(1) diff --git a/internal/controller/namespace_controller.go b/internal/controller/namespace_controller.go index 1d70601..de86c1d 100644 --- a/internal/controller/namespace_controller.go +++ b/internal/controller/namespace_controller.go @@ -41,12 +41,12 @@ import ( ) const ( - valkeyACLAnnotation = "deco.sites/valkey-acl" - valkeyACLFinalizer = "deco.sites/valkey-acl" - valkeySecretName = "valkey-acl" - siteNamespacePrefix = "sites-" - valkeyReservedDefault = "default" - NamespaceControllerName = "namespace" + valkeyACLAnnotation = "deco.sites/valkey-acl" + valkeyACLFinalizer = "deco.sites/valkey-acl" + valkeySecretName = "valkey-acl" + siteNamespacePrefix = "sites-" + valkeyReservedDefault = "default" + TenantControllerName = "tenant" ) // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;update;patch @@ -61,7 +61,7 @@ const DefaultResyncPeriod = 10 * time.Minute // valkeyACLAnnotationValue is the expected value of the opt-in annotation. const valkeyACLAnnotationValue = "true" -// NamespaceReconciler provisions per-tenant Valkey ACL credentials for site namespaces. +// TenantReconciler provisions per-tenant Valkey ACL credentials for site namespaces. // When a Namespace has the annotation "deco.sites/valkey-acl: true", the reconciler: // - Creates a Valkey ACL user restricted to the site's key prefix. // - Creates a K8s Secret "valkey-acl" in that namespace with the credentials. @@ -95,7 +95,7 @@ const valkeyACLAnnotationValue = "true" // // TODO: when enabling auth, extend ValkeyClient to provision ACL SETUSER on // all nodes (master + every replica), not only the Sentinel master. -type NamespaceReconciler struct { +type TenantReconciler struct { client.Client Scheme *runtime.Scheme ValkeyClient valkey.Client @@ -105,7 +105,7 @@ type NamespaceReconciler struct { // ProvisionSingleNode re-provisions all managed namespaces on one specific Valkey // node. Called when Sentinel detects a replica restart (+reboot/-sdown events) so // only the restarted node is updated — no unnecessary work on healthy nodes. -func (r *NamespaceReconciler) ProvisionSingleNode(ctx context.Context, nodeAddr string) { +func (r *TenantReconciler) ProvisionSingleNode(ctx context.Context, nodeAddr string) { log := logf.FromContext(ctx).WithName("valkey-node-provision").WithValues("node", nodeAddr) nsList := &corev1.NamespaceList{} if err := r.List(ctx, nsList); err != nil { @@ -142,7 +142,7 @@ func (r *NamespaceReconciler) ProvisionSingleNode(ctx context.Context, nodeAddr // TriggerResyncAll immediately re-queues all managed namespaces by updating a // sync annotation. Called on Sentinel failover events to recover ACLs without // waiting for the next periodic resync cycle. -func (r *NamespaceReconciler) TriggerResyncAll(ctx context.Context) { +func (r *TenantReconciler) TriggerResyncAll(ctx context.Context) { log := logf.FromContext(ctx).WithName("valkey-resync") nsList := &corev1.NamespaceList{} if err := r.List(ctx, nsList); err != nil { @@ -172,7 +172,7 @@ func (r *NamespaceReconciler) TriggerResyncAll(ctx context.Context) { // InitMetrics seeds the tenants_provisioned gauge from current cluster state. // Must be called after the cache is synced (i.e. inside a Runnable or after mgr.Start). -func (r *NamespaceReconciler) InitMetrics(ctx context.Context) error { +func (r *TenantReconciler) InitMetrics(ctx context.Context) error { nsList := &corev1.NamespaceList{} if err := r.List(ctx, nsList); err != nil { return err @@ -197,7 +197,7 @@ func (r *NamespaceReconciler) InitMetrics(ctx context.Context) error { // SetupWithManager registers the Namespace controller with a resync period for // self-healing (recovers ACLs lost after a Valkey restart). -func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error { +func (r *TenantReconciler) SetupWithManager(mgr ctrl.Manager) error { // Watch Secrets named "valkey-acl" and enqueue the parent Namespace. // Namespace is cluster-scoped so Owns() (which relies on owner references) cannot // be used across scopes. Instead we map Secret → Namespace by name. @@ -222,7 +222,7 @@ func (r *NamespaceReconciler) SetupWithManager(mgr ctrl.Manager) error { Complete(r) } -func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { +func (r *TenantReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { log := logf.FromContext(ctx).WithValues("namespace", req.Name) ns := &corev1.Namespace{} @@ -340,7 +340,7 @@ func (r *NamespaceReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // createSecret creates the "valkey-acl" Secret in the given namespace with // credentials ready to be consumed by deco via LOADER_CACHE_REDIS_USERNAME/PASSWORD. -func (r *NamespaceReconciler) createSecret(ctx context.Context, namespace, username, password string) error { +func (r *TenantReconciler) createSecret(ctx context.Context, namespace, username, password string) error { secret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ Name: valkeySecretName, From 84ca738192e9e6d688a4457ba138b22226250aa1 Mon Sep 17 00:00:00 2001 From: igoramf Date: Fri, 29 May 2026 10:49:49 -0300 Subject: [PATCH 13/13] fix(build): build ./cmd instead of cmd/main.go to include all cmd package files --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 425bae2..73e3104 100644 --- a/Makefile +++ b/Makefile @@ -202,7 +202,7 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration .PHONY: build build: manifests generate fmt vet ## Build manager binary. - go build -o bin/manager cmd/main.go + go build -o bin/manager ./cmd .PHONY: run run: manifests generate fmt vet ## Run a controller from your host.