Skip to content

feat: operator migration - #309

Open
emilic wants to merge 74 commits into
mainfrom
feat/operator-migration
Open

emilic wants to merge 74 commits into
mainfrom
feat/operator-migration

Conversation

@emilic

@emilic emilic commented Apr 10, 2026

Copy link
Copy Markdown
Contributor

Description

What problem is being solved?

The OpenFGA Helm chart uses Helm hooks (post-install, post-upgrade) and a k8s-wait-for init container to orchestrate database migrations. This approach breaks in multiple ways:

  1. ArgoCD ignores Helm hooks — the migration Job is never created, so the init container waits forever (Initial chart deployment fails: Error from server (NotFound): jobs.batch "openfga-migrate" not found #211, Migrate job not showing up in ArgoCD #107)
  2. helm install --wait deadlocks — the init container waits for the hook Job, but the hook only runs after the Deployment is ready ("hen egg" problem when used with helm deploy #120)
  3. FluxCD's hook-delete-policy conflict — the Job is cleaned up before FluxCD can confirm the Deployment is healthy (Hooked job deletion incompatible with Helm --wait and FluxCD by default #100)
  4. Migration and runtime share a ServiceAccount — the runtime gets excessive DDL privileges that should only be needed during migration (Migration job and deployment use the same service account #95)
  5. k8s-wait-for is unmaintained — 2+ years stale, known CVEs, pinned by tag not digest (Reuse k8s-wait-for image repo and tag across migrate job and initContainer #126, k8s-wait-for image is inactively maintained and contains many vulnerabilities #132, An external dependency to groundnuty/k8s-wait-for is pinned using tag. #144)

These represent the single biggest pain point for users of the chart.

How is it being solved?

A lightweight Kubernetes operator moves migration orchestration from deploy-time Helm hooks into the runtime control plane. The operator watches OpenFGA Deployments, detects version changes, and runs migrations as regular Kubernetes Jobs — no hooks, no init containers.

This is Stage 1 of the operator, focused solely on migration orchestration. It does not introduce any CRDs and never changes the Deployment's replica count or pod template. The operator scopes its watch to Deployments matching the labels app.kubernetes.io/part-of: openfga and app.kubernetes.io/component: authorization-controller, and only reconciles those that opt in via the openfga.dev/migration-enabled: "true" annotation emitted by the chart for Postgres and MySQL.

What changes are made to solve it?

New: Go operator (operator/)

  • controller-runtime based reconciler that watches OpenFGA Deployments in its namespace
  • The migration identity is the image tag plus an openfga.dev/migration-trigger annotation the chart derives from the datastore connection settings (engine, host and database, Secret names and keys; never credentials). When it differs from the {name}-migration-status ConfigMap the operator creates a Job running openfga migrate, waits for completion via Job conditions, and records the identity. So a new image, or the same image pointed at another database, both migrate; a password change or a pod template change does not
  • The Job is built from the Deployment's pod spec: the OpenFGA container's image, env, envFrom, volume mounts, resources, security context, image pull policy and secrets, and scheduling; the other containers as native sidecars (restartPolicy: Always, so a database proxy is available and stops when the migration exits); the init containers. migrate.* values (labels, non-hook annotations such as sidecar.istio.io/inject: "false", extra volumes and mounts, init containers, sidecars, timeout) and datastore.migrations.resources are forwarded through Deployment annotations and applied on top. Least privilege is enforced at the RBAC layer via a dedicated migration ServiceAccount, not by filtering env vars
  • Readiness comes from OpenFGA itself: on a fresh database pods stay NotReady until the schema reaches MinimumSupportedDatastoreSchemaRevision (4 since v1.3.x). On upgrades the existing schema already meets it, so new pods serve on the previous schema while the Job applies the newer migrations, the same as the Helm hook flow. See ADR-002
  • A running migration is never interrupted: a Job whose pod is Ready is left to finish even if the image changes again (rollback, two upgrades in a row), then replaced. Replacement uses foreground deletion with UID and resourceVersion preconditions so two migrations never overlap. A Job whose pod cannot start (bad secret reference, image pull error, unschedulable) is rebuilt once the Deployment's pod template changes
  • Only Jobs the operator created or the chart's legacy hook Job are ever replaced, and only a status ConfigMap owned by the Deployment is trusted. A same-name resource from elsewhere blocks the migration with a MigrationJobConflict event until it is removed
  • On failure: sets a MigrationFailed condition on the Deployment, keeps the failed Job for 60s so its logs can be read, then replaces it. The delay is measured from the Job's own condition, so it survives operator restarts, and the operator self-heals when the database comes back. ttlSecondsAfterFinished is applied only after success, so failed Jobs are never garbage-collected before the retry and a completed Job is never removed before its outcome is recorded
  • Kubernetes events on the Deployment: MigrationStarted, MigrationSucceeded, MigrationFailed, MigrationJobConflict
  • Container resolved via the openfga.dev/container-name annotation (returns an error for misconfigured Deployments instead of silently picking the first container)
  • Job knobs (backoffLimit, activeDeadlineSeconds, ttlSecondsAfterFinished) are configurable via Helm values and validated at operator startup. There is no deadline by default, like the hook Job, so long index builds and MySQL table rebuilds are not cut off halfway
  • Multi-stage Dockerfile (distroless runtime) built with Go 1.26.8; govulncheck, golangci-lint, hadolint and kube-linter report nothing
  • Unit tests using the controller-runtime fake client

New: Operator Helm subchart (charts/openfga-operator/)

  • Deployment, namespaced Role and RoleBinding in the watch namespace, ServiceAccount, optional PodDisruptionBudget
  • Restricted PSS-compliant security context (allowPrivilegeEscalation: false, read-only root FS, non-root, dropped caps)
  • Default resource requests/limits, values.schema.json validation, .helmignore
  • serviceAccount.create: false requires an explicit serviceAccount.name (prevents silently binding operator RBAC to default)
  • Configurable via values.yaml: leader election, watch namespace, namespace override, resource limits, Job backoff/deadline/TTL, PDB, rbac.create, metrics.enabled (controller-runtime metrics are off unless enabled, since the endpoint is unauthenticated)
  • Artifact Hub operator, operatorCapabilities and signKey annotations, same GPG key as the openfga chart

Modified: Parent chart (charts/openfga/)

  • The operator subchart is enabled with openfga-operator.enabled (defaults to false), following the same convention as postgresql.enabled and mysql.enabled, so its toggle and configuration live under one key. With it disabled the rendered output is identical to main
  • When enabled with a Postgres or MySQL datastore and datastore.applyMigrations: true:
    • Deployment emits the openfga.dev/migration-enabled, openfga.dev/container-name, openfga.dev/migration-trigger annotations, optionally openfga.dev/migration-service-account, and the openfga.dev/migration-* annotations carrying the migrate.* values
    • spec.replicas is rendered as before (omitted when autoscaling.enabled), so kubectl scale, HPAs, and GitOps tools behave the same with or without the operator
    • The legacy init containers, hook Job, and hook RBAC are skipped
    • The migration Job runs as the OpenFGA ServiceAccount, as the hook Job did; migration.serviceAccount.create gives it a dedicated one for cloud IAM annotations
  • migration.trigger: set to any new value to run the migration again when neither the image nor the datastore settings changed, e.g. after rotating a Secret under the same name to point at another database
  • values.schema.json updated for the openfga-operator.enabled, migration.trigger, migration.serviceAccount and migrate.labels properties
  • Chart README documents operator mode

CI

  • New .github/workflows/operator.yml: Go tests with -race, gofmt, and vet; multi-platform (amd64 + arm64) image build for PRs touching the operator, pushed to ghcr.io/{owner}/openfga-operator from main, the :<appVersion> tag published once per version. Pushed images carry an SBOM and build provenance and are signed with cosign keyless, verified in the same job; the operator README has the cosign verify command. Actions are pinned to commit SHAs
  • .github/scripts/check-operator-release.sh: fails a PR that changes the operator image or chart without bumping the operator chart's appVersion/version, the openfga chart version, its dependency pin and Chart.lock, so every change is actually released. Comes with a test matrix (check-operator-release_test.sh)
  • release.yml is unchanged: chart publishing does not depend on the operator image build, same as for the openfga/openfga image
  • test.yml: helm-unittest for the operator chart, the operator image loaded into kind, chart-testing, and an operator + Postgres E2E that installs v1.9.5 and upgrades to the chart's appVersion across the v1.10.0 migration. These also run for operator-only changes
  • Dependabot covers the operator Go module and Dockerfile

New: Integration test values (operator/tests/)

  • Happy path, database outage & recovery, and permanent failure scenarios
  • README with step-by-step verification instructions for local Kubernetes clusters

New: Architecture Decision Records (docs/adr/)

  • ADR-001: operator vs. alternatives
  • ADR-002: operator-managed migrations (why the operator leaves the replica count to the chart, where readiness comes from, failure handling)

How each issue is resolved:

Issue Resolution
#211, #107 (ArgoCD) No Helm hooks — migration Job is a regular resource, visible to ArgoCD
#120 (--wait deadlock) No init container, no hook Job. On a fresh install helm install --wait returns once the operator has migrated and the pods are ready; upgrades roll out as usual
#100 (FluxCD) No hook-delete-policy — operator manages Job lifecycle with TTL and explicit cleanup
#95 (shared SA) Dedicated migration ServiceAccount with DDL permissions, separate from runtime SA
#126, #132, #144 (k8s-wait-for) Eliminated entirely — operator watches Job status directly

Testing

Verified on kind (Kubernetes 1.37) with Postgres 17 and MySQL 8.4, using a small probe app that seeds a store (groups, nested folders, conditions, wildcard access, ~4k tuples), checks 62 expected authorization results, and keeps ~5k requests/s running during upgrades:

  • Fresh installs with helm install --wait on Helm 3.12.1, 3.22, and 4.3
  • Upgrades v1.5.9 → v1.9.5 → v1.10.0 → v1.14.1 → v1.21.0 on Postgres, and v1.9.5 → v1.21.0 on MySQL (migrations 007 and 008). Data was intact after every step, and the only request errors came from old pods shutting down, the same as a legacy-mode rolling update
  • Switching an existing release from legacy to operator mode (Helm 3 and 4) and back, under load: replica count unchanged, the leftover hook Job replaced, and the migration run
  • helm rollback to an older version, database outage with retries (including an operator restart), operator down during a migration, leader election failover, two releases in one namespace, operator in a separate namespace, memory engine, HPA, deleting the status ConfigMap, GitOps-style server-side apply with no drift
  • A second upgrade while a migration was running (index build held back by an open transaction): the running Job was left to finish, then replaced by one for the new image
  • A Job stuck on a bad Secret key, rebuilt after helm upgrade fixed the key; a database proxy sidecar (socat) with the URI on localhost; ttlSecondsAfterFinished: 0; a foreign same-name Job left untouched with a MigrationJobConflict event; repointing datastore.uri at an empty database ran the migration, a password-only change and a log.level change did not
  • The test.yml operator E2E step replayed locally with Helm 3.12.1

References

Review Checklist

  • I have clicked on "allow edits by maintainers".
  • I have added documentation for new/changed functionality in this PR or in a PR to openfga.dev
  • The correct base branch is being used, if not main
  • I have added tests to validate that the change in functionality is working as expected

Copilot AI review requested due to automatic review settings April 10, 2026 17:10
@emilic
emilic requested review from a team as code owners April 10, 2026 17:10
@coderabbitai

coderabbitai Bot commented Apr 10, 2026

Copy link
Copy Markdown

Walkthrough

This pull request introduces a Kubernetes-native operator to orchestrate OpenFGA database migrations, replacing the existing Helm hook-based approach. The implementation includes a controller-runtime operator in Go, a Helm subchart for operator deployment, integration into the main OpenFGA chart, comprehensive testing, and architectural documentation via ADRs.

Changes

Cohort / File(s) Summary
Operator Go Implementation
operator/cmd/main.go, operator/go.mod, operator/internal/controller/migration_controller.go, operator/internal/controller/migration_controller_test.go, operator/internal/controller/helpers.go
Core operator logic: CLI flag parsing, manager setup, migration reconciliation loop, deployment/job/configmap orchestration, helper functions for image tag extraction and replica management; comprehensive unit tests covering migration states, job lifecycle, failure handling, and retry logic.
Operator Build & Config
operator/Dockerfile, operator/Makefile, operator/.dockerignore
Two-stage Docker build for static binary, development Makefile with build/test/lint/docker targets, and ignore patterns.
Operator Helm Chart
charts/openfga-operator/Chart.yaml, charts/openfga-operator/values.yaml, charts/openfga-operator/templates/_helpers.tpl, charts/openfga-operator/templates/deployment.yaml, charts/openfga-operator/templates/serviceaccount.yaml, charts/openfga-operator/templates/role.yaml, charts/openfga-operator/templates/rolebinding.yaml, charts/openfga-operator/templates/pdb.yaml, charts/openfga-operator/templates/NOTES.txt, charts/openfga-operator/crds/README.md
Complete Helm subchart with manifests for Deployment, RBAC (Role/RoleBinding), ServiceAccount, PodDisruptionBudget, and helper templates for naming/labeling; values configuration for replicas, image, security contexts, leader election, and resource limits.
OpenFGA Chart Integration
charts/openfga/Chart.yaml, charts/openfga/values.yaml, charts/openfga/values.schema.json
Declares operator subchart as conditional dependency; adds operator.enabled and migration.* configuration options with schema validation.
OpenFGA Deployment & RBAC Updates
charts/openfga/templates/deployment.yaml, charts/openfga/templates/rbac.yaml, charts/openfga/templates/job.yaml, charts/openfga/templates/serviceaccount.yaml, charts/openfga/templates/_helpers.tpl
Conditional logic to suppress Helm hook-based migration when operator is enabled; separates migration ServiceAccount creation; gates RBAC to operator-disabled mode; gates migration init containers when operator is active; adds migration annotations and replica control when operator/migration both enabled.
Helm Chart Tests
charts/openfga/tests/operator_mode_test.yaml, charts/openfga/tests/operator_mode_job_test.yaml, charts/openfga/tests/operator_mode_rbac_test.yaml, charts/openfga/tests/operator_mode_serviceaccount_test.yaml
Unit test suites validating annotation behavior, replica scaling, RBAC conditional rendering, ServiceAccount separation, and mutual exclusivity constraints (operator + autoscaling).
Integration Testing & CI
.github/workflows/operator.yml, .github/workflows/test.yml, operator/tests/README.md, operator/tests/values-*.yaml
GitHub Actions workflow for operator build/test/push; manual integration test guide with three scenarios (happy path, DB outage/recovery, permanent failure); test values files configuring isolated Postgres instances and operator image loading.
Architecture Documentation
docs/adr/README.md, docs/adr/000-template.md, docs/adr/001-adopt-openfga-operator.md, docs/adr/002-operator-managed-migrations.md, docs/adr/003-declarative-store-lifecycle-crds.md, docs/adr/004-operator-deployment-model.md
ADR process framework and four architectural decision records: operator adoption rationale, Stage 1 migration orchestration control flow, future declarative CRD stages (Stores/Models/Tuples), and Helm installation/dependency model with CRD handling.
Operator Documentation
operator/README.md
Stage 1 operator behavior, deployment lifecycle, failure modes, prerequisites, build/test/integration instructions, operator flags, and deployment annotations.

Sequence Diagram

sequenceDiagram
    participant User as GitOps/User
    participant Helm as Helm Install
    participant K8s as Kubernetes
    participant Op as Migration Operator
    participant DB as Database
    
    User->>Helm: helm install (operator.enabled=true)
    Helm->>K8s: Create Deployment (replicas=0, annotations)
    Helm->>K8s: Create MigrationReconciler
    Helm->>K8s: Return immediately
    Note over K8s: Operator starts watching...
    
    K8s->>Op: Detect Deployment with migration enabled
    Op->>Op: Read current migration version from ConfigMap
    Op->>Op: Extract desired version from image tag
    Note over Op: Versions differ → Migration needed
    
    Op->>K8s: Scale Deployment replicas → 0
    Op->>K8s: Create migration Job (runs openfga migrate)
    K8s->>DB: Job executes migration SQL
    DB-->>K8s: Migration succeeds
    K8s->>Op: Job marked as Complete
    
    Op->>K8s: Update migration-status ConfigMap (new version)
    Op->>K8s: Scale Deployment replicas → desired count
    K8s->>K8s: Deployment pods start (migration complete)
    K8s-->>User: OpenFGA ready
    
    Note over Op,User: On migration failure:
    Note over Op: Set MigrationFailed condition
    Note over Op: Delete failed Job after backoff
    Note over Op: Requeue with 60s cooldown
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The pull request comprehensively addresses all coding requirements from linked issues: removes Helm hooks (#211, #107), eliminates init-container deadlocks (#120), manages Job lifecycle without hook-d…
Out of Scope Changes check ✅ Passed All changes are directly scoped to operator implementation: new operator codebase, Helm charts (openfga-operator subchart and parent chart modifications), CI/CD workflows, documentation (ADRs), and te…
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the main change: adding operator-managed migration support. It is concise and related to the pull request scope.
  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/operator-migration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@socket-security

socket-security Bot commented Apr 10, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedsigs.k8s.io/​controller-runtime@​v0.23.373100100100100
Addedk8s.io/​apimachinery@​v0.35.374100100100100
Addedk8s.io/​client-go@​v0.35.37510010075100
Addedk8s.io/​api@​v0.35.376100100100100
Addedk8s.io/​utils@​v0.0.0-20260319190234-28399d86e0b588100100100100

View full report

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces an OpenFGA migration operator and integrates it into the existing Helm chart to replace Helm hook/initContainer-based migration orchestration (improving compatibility with GitOps tools and helm --wait).

Changes:

  • Added a new Go-based operator (controller-runtime) that orchestrates migrations via Kubernetes Jobs and a migration-status ConfigMap.
  • Added an openfga-operator Helm subchart and integrated it as an optional dependency of the parent openfga chart (operator.enabled).
  • Added ADR documentation and local integration test values/instructions for validating operator-driven migration behavior.

Reviewed changes

Copilot reviewed 37 out of 39 changed files in this pull request and generated 13 comments.

Show a summary per file
File Description
operator/tests/values-no-db.yaml Values for permanent DB failure integration scenario
operator/tests/values-happy-path.yaml Values + embedded Postgres manifests for happy-path scenario
operator/tests/values-db-outage.yaml Values + Postgres scaled-to-0 outage/recovery scenario
operator/tests/README.md Step-by-step local integration test instructions
operator/README.md Operator overview, dev commands, and configuration docs
operator/Makefile Basic build/test/vet/lint and docker targets for the operator
operator/internal/controller/migration_controller.go Core reconciliation logic for scaling, Job orchestration, ConfigMap status
operator/internal/controller/migration_controller_test.go Unit tests for reconcile paths and image tag parsing
operator/internal/controller/helpers.go Job builder + scaling + ConfigMap helpers
operator/go.mod Operator module definition and dependencies
operator/go.sum Dependency lockfile for operator module
operator/Dockerfile Multi-stage build producing distroless runtime image
operator/cmd/main.go Operator entrypoint, flags, manager setup, health endpoints
operator/.dockerignore Docker context exclusions for faster builds
docs/adr/README.md ADR index and ADR process documentation
docs/adr/004-operator-deployment-model.md ADR for deploying operator as Helm subchart dependency
docs/adr/003-declarative-store-lifecycle-crds.md ADR for future CRD-based store/model/tuples management
docs/adr/002-operator-managed-migrations.md ADR describing migration flow moved from Helm hooks to operator
docs/adr/001-adopt-openfga-operator.md ADR establishing the operator approach and staged roadmap
docs/adr/000-template.md ADR template for future architectural decisions
charts/openfga/values.yaml Added operator + migration values surface
charts/openfga/values.schema.json Schema updates for new operator/migration values and subchart passthrough
charts/openfga/templates/serviceaccount.yaml Adds optional dedicated migration ServiceAccount in operator mode
charts/openfga/templates/rbac.yaml Skips legacy hook/init-container RBAC when operator is enabled
charts/openfga/templates/job.yaml Skips legacy Helm hook migration Job when operator is enabled
charts/openfga/templates/deployment.yaml Operator mode annotations, replicas=0 behavior, disables hook init-container path
charts/openfga/templates/_helpers.tpl Helper for migration ServiceAccount naming
charts/openfga/Chart.yaml Adds openfga-operator dependency gated by operator.enabled
charts/openfga/Chart.lock Lockfile updated to include the new subchart dependency
charts/openfga-operator/values.yaml Operator chart configuration (image, leader election, watch scope, resources)
charts/openfga-operator/templates/serviceaccount.yaml Operator ServiceAccount manifest
charts/openfga-operator/templates/NOTES.txt Post-install notes for operator chart
charts/openfga-operator/templates/deployment.yaml Operator Deployment manifest and CLI args wiring
charts/openfga-operator/templates/clusterrolebinding.yaml ClusterRoleBinding for operator permissions
charts/openfga-operator/templates/clusterrole.yaml ClusterRole for operator (deployments/jobs/configmaps/etc.)
charts/openfga-operator/templates/_helpers.tpl Operator chart naming/labels helpers
charts/openfga-operator/crds/README.md Placeholder note for future CRDs (Stage 2+)
charts/openfga-operator/Chart.yaml Operator chart metadata/versioning
.github/workflows/operator.yml CI workflow for operator tests + multi-arch build/push

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread operator/internal/controller/migration_controller.go Outdated
Comment thread operator/internal/controller/migration_controller.go Outdated
Comment thread charts/openfga/templates/deployment.yaml Outdated
Comment thread charts/openfga/templates/deployment.yaml Outdated
Comment thread charts/openfga/values.yaml Outdated
Comment thread operator/internal/controller/migration_controller.go Outdated
Comment thread operator/internal/controller/helpers.go Outdated
Comment thread operator/README.md Outdated
Comment thread charts/openfga-operator/templates/deployment.yaml Outdated
Comment thread operator/internal/controller/migration_controller.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 17

🧹 Nitpick comments (1)
operator/tests/values-no-db.yaml (1)

18-18: Avoid inline credentials in test fixture URIs.

Line 18 embeds basic-auth credentials, which triggers secret scanners and normalizes plaintext credential patterns. Prefer a redacted placeholder for this failure-path fixture.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@operator/tests/values-no-db.yaml` at line 18, Replace the inline basic-auth
credentials embedded in the YAML "uri" value (the failing PostgreSQL URI) with a
redacted placeholder or environment-variable reference (e.g., remove
"openfga:changeme@" and replace with a token like "<REDACTED_CREDENTIALS>" or
use an env var reference) so the test fixture no longer contains plaintext
credentials; update the "uri" key's value accordingly and ensure any test code
reading this fixture can handle the placeholder or env var.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/operator.yml:
- Around line 7-11: The workflow path filters are too narrow; update the
operator workflow (operator.yml) to also trigger on changes that affect image
publishing and self-validation by adding the chart and workflow files to the
paths filter — include at minimum "charts/openfga-operator/Chart.yaml" and
".github/workflows/operator.yml" (or broader "charts/**" and
".github/workflows/**") so that functions relying on Chart.yaml-derived tags and
workflow self-changes will cause the workflow to run.

In `@charts/openfga-operator/templates/clusterrole.yaml`:
- Around line 20-25: Remove the unused Secret and ServiceAccount permissions
from the operator ClusterRole template: delete the rules that list resources
"secrets" with verb "get" and "serviceaccounts" with verbs "get","list","create"
in the charts/openfga-operator/templates/clusterrole.yaml so the ClusterRole
only grants permissions actually used by the controller (referenced by
migration_controller.go and helpers.go which do not access Secrets or create
ServiceAccounts); ensure no other ClusterRole rules depend on those resource
entries and run a quick helm template to validate the manifest.

In `@charts/openfga-operator/templates/deployment.yaml`:
- Around line 27-36: The deployment template leaves podSecurityContext and
container securityContext empty (podSecurityContext and securityContext blocks)
which allows runtime-default privileges; update the chart defaults in
values.yaml and the deployment template to enable hardened defaults: set
podSecurityContext with runAsNonRoot: true and runAsUser (non-root uid), and set
container securityContext to runAsNonRoot: true, runAsUser, seccompProfile: {
type: RuntimeDefault }, capabilities: { drop: ["ALL"] }, and
readOnlyRootFilesystem: true; ensure these keys are present and not commented
out so the operator container and pod inherit the hardened defaults (refer to
podSecurityContext and the container securityContext entries in the deployment
template and the commented hardening directives in values.yaml).

In `@charts/openfga-operator/templates/NOTES.txt`:
- Around line 3-5: Replace the hard assertion in the Helm NOTES template that
the operator image "({{ .Values.image.repository }}:{{ .Values.image.tag |
default .Chart.AppVersion }}) does not exist yet" with a neutral, conditional
message: detect if a developer flag or a value (e.g., .Values.image.isDev or by
checking if .Values.image.tag equals .Chart.AppVersion) indicates a development
build and only then warn that the image may not be pushed; otherwise emit a
neutral info line that the operator uses the specified image without asserting
non-existence. Update the template text in NOTES.txt around the referenced
symbols (.Values.image.repository, .Values.image.tag, .Chart.AppVersion) to
reflect this conditional/neutral wording.

In `@charts/openfga/templates/_helpers.tpl`:
- Around line 80-85: The helper openfga.migrationServiceAccountName currently
always returns a generated name; change it to first check
.Values.migration.serviceAccount.create and
.Values.migration.serviceAccount.name, and if create is false and name is not
provided, return an empty string (so operator-created migration Jobs won't
reference a non-existent SA). Update the logic in the
openfga.migrationServiceAccountName function to: if
.Values.migration.serviceAccount.name use that (trunc/trimSuffix), else if
.Values.migration.serviceAccount.create is false return "" immediately,
otherwise generate and return the fallback printf "%s-migration" (include
"openfga.fullname" .) with trunc/trimSuffix.

In `@charts/openfga/templates/deployment.yaml`:
- Around line 8-10: The template is honoring only .Values.operator.enabled but
must also respect .Values.migration.enabled; update the conditional logic
wherever migration-related annotations/fields are set (e.g., the blocks that set
openfga.dev/desired-replicas, openfga.dev/migration-service-account, and any
migration init-container toggles) to require both .Values.operator.enabled and
.Values.migration.enabled (e.g., change conditionals to check both flags before
emitting those annotations/fields), and apply the same fix to the other
occurrences noted (lines ~16-23 and ~49) so operator-mode branches do not inject
migration annotations or set replicas to 0 when migration.enabled is false.

In `@docs/adr/002-operator-managed-migrations.md`:
- Around line 119-125: The ADR currently states "exponential backoff" for
database connectivity retries but the controller implements a fixed retry
cadence; update the ADR text in the "Database unreachable" row to document the
actual retry policy used by the controller (replace "exponential backoff" with
the fixed 60s retry), and mention how this interacts with Job reschedules and
operator restart behavior (keep references to MigrationFailed condition,
activeDeadlineSeconds, ConfigMap re-read and Job status resume semantics to
preserve context).

In `@docs/adr/004-operator-deployment-model.md`:
- Around line 36-39: Update the ADR text and all examples to reflect that the
operator is disabled by default: change the description of the "Operator as a
conditional subchart dependency (selected)" option from "Enabled by default" to
"Disabled by default", update the examples to show how to enable it by setting
operator.enabled: true, and adjust the other occurrences that claim it is
enabled by default (mentions of openfga-operator and any numbered sections
referenced around lines 74-75 and 127-128) so they align with the actual default
in values.schema.json where operator.enabled is false.

In `@docs/adr/README.md`:
- Around line 33-39: The fenced code block in docs/adr/README.md is missing a
language label which triggers markdownlint; edit that block (the diagram block
containing "Proposed → Accepted → (optionally) Superseded or Deprecated" and the
feedback loop arrows) to add a language specifier (e.g., use "text") immediately
after the opening backticks so the block becomes a labeled fenced code block.

In `@operator/cmd/main.go`:
- Around line 40-70: Default flags leave the operator watching all namespaces;
ensure it falls back to the pod's namespace by reading the POD_NAMESPACE env var
when watchNamespace is empty and watchAllNamespaces is false, and set
cacheOpts.DefaultNamespaces accordingly before calling ctrl.NewManager. In
practice, add an os.LookupEnv("POD_NAMESPACE") check after flag.Parse() and, if
watchNamespace == "" && !watchAllNamespaces && POD_NAMESPACE is present, set
cacheOpts.DefaultNamespaces = map[string]cache.Config{podNamespace: {}}
(referencing watchNamespace, watchAllNamespaces, cacheOpts.DefaultNamespaces,
and ctrl.NewManager), and also ensure the chart/deployment injects POD_NAMESPACE
into the operator pod env.

In `@operator/go.mod`:
- Line 3: The Docker builder image is using a floating patch tag (FROM
golang:1.25) while operator/go.mod pins go 1.25.6; update the Dockerfile's base
image to the exact patch version to match go.mod by changing the FROM
golang:1.25 line to FROM golang:1.25.6 so builds are reproducible and aligned
with the declared go 1.25.6 in operator/go.mod.

In `@operator/internal/controller/helpers.go`:
- Around line 120-142: The Job pod template currently copies
NodeSelector/Tolerations/Affinity but omits imagePullSecrets and security
contexts; update the PodTemplateSpec in the migration Job (where Template:
corev1.PodTemplateSpec is built) to also set Spec.ImagePullSecrets =
deployment.Spec.Template.Spec.ImagePullSecrets, Spec.SecurityContext =
deployment.Spec.Template.Spec.SecurityContext, and copy the container-level
SecurityContext into the "migrate-database" container
(Containers[0].SecurityContext =
deployment.Spec.Template.Spec.Containers[i].SecurityContext for the matching
mainContainer.Image/name), ensuring the migration container keeps the same
Image/Args/Env while inheriting the source Deployment's imagePullSecrets and
security settings.

In `@operator/internal/controller/migration_controller.go`:
- Around line 49-55: The code incorrectly assumes the main OpenFGA container is
Containers[0]; update the logic to locate the OpenFGA container by name (e.g.,
look for container.Name == "openfga" or the configured main container name)
instead of indexing 0, retrieve its Image and env once (store the resolved core
container object or its index) and pass or reuse that reference when calling
extractImageTag and buildMigrationJob so both use the same resolved container;
update any checks (previous len/Containers[0] usages) to handle missing named
container and return/skip with a clear log if not found.
- Around line 108-121: The success branch handling job.Status.Succeeded >= 1
never clears a prior MigrationFailed condition; update the logic so that after
logging success and before returning it clears/removes the MigrationFailed
condition in the migration status ConfigMap. Specifically, in the
job.Status.Succeeded block (around updateMigrationStatus and
ensureDeploymentScaled), modify or extend updateMigrationStatus (or call a new
helper) to set the MigrationFailed condition to false/remove it for
desiredVersion/jobName so kubectl describe no longer shows a stale failure; keep
the existing replica scaling flow (ensureDeploymentScaled) and return only after
the status has been updated.
- Around line 129-149: The 60s backoff is being bypassed because deleting the
failed Job immediately requeues the owning Deployment; fix by persisting a
retry-until timestamp on the Deployment before deleting the Job and checking
that timestamp during job-creation logic. Specifically: in the failure branch
where you call setMigrationFailedCondition(deployment, desiredVersion) and
r.Status().Update(...) (and before r.Delete(...)), set an annotation or a field
on deployment.Status (e.g., "migration.retryUntil" =
time.Now().Add(60*time.Second).UTC().Format(time.RFC3339)) and persist it
(handle update conflicts); then in the reconcile path that decides to create a
new migration Job (the code that currently creates the Job when IsNotFound),
read and parse that "migration.retryUntil" value and skip Job creation until the
timestamp has passed (clear the annotation/status field when creating the Job).
This ensures the controller honors the cooldown even though the owned Job watch
will requeue the Deployment.

In `@operator/Makefile`:
- Around line 5-6: The build target fails when bin/ is missing; update the
Makefile's build target (the "build:" target) to ensure the output directory
exists by creating bin before running go build (use mkdir -p bin) so that go
build -o bin/operator ./cmd/ never errors on fresh clones or after make clean.

In `@operator/README.md`:
- Around line 9-15: Update the README text that currently references the
hard-coded ConfigMap name "openfga-migration-status" to use the
deployment-scoped ConfigMap name pattern used elsewhere in the PR (e.g.,
"<release>-migration-status"); specifically, edit the line describing version
comparison so it no longer mentions "openfga-migration-status" but instead
references the deployment-scoped ConfigMap name pattern
("<release>-migration-status" or equivalent placeholder) so docs and tests point
to the correct resource.

---

Nitpick comments:
In `@operator/tests/values-no-db.yaml`:
- Line 18: Replace the inline basic-auth credentials embedded in the YAML "uri"
value (the failing PostgreSQL URI) with a redacted placeholder or
environment-variable reference (e.g., remove "openfga:changeme@" and replace
with a token like "<REDACTED_CREDENTIALS>" or use an env var reference) so the
test fixture no longer contains plaintext credentials; update the "uri" key's
value accordingly and ensure any test code reading this fixture can handle the
placeholder or env var.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8f9a57a5-18a8-4395-92d9-b4eae0a8affc

📥 Commits

Reviewing files that changed from the base of the PR and between fed787a and 7684485.

⛔ Files ignored due to path filters (2)
  • charts/openfga/Chart.lock is excluded by !**/*.lock
  • operator/go.sum is excluded by !**/*.sum
📒 Files selected for processing (37)
  • .github/workflows/operator.yml
  • charts/openfga-operator/Chart.yaml
  • charts/openfga-operator/crds/README.md
  • charts/openfga-operator/templates/NOTES.txt
  • charts/openfga-operator/templates/_helpers.tpl
  • charts/openfga-operator/templates/clusterrole.yaml
  • charts/openfga-operator/templates/clusterrolebinding.yaml
  • charts/openfga-operator/templates/deployment.yaml
  • charts/openfga-operator/templates/serviceaccount.yaml
  • charts/openfga-operator/values.yaml
  • charts/openfga/Chart.yaml
  • charts/openfga/templates/_helpers.tpl
  • charts/openfga/templates/deployment.yaml
  • charts/openfga/templates/job.yaml
  • charts/openfga/templates/rbac.yaml
  • charts/openfga/templates/serviceaccount.yaml
  • charts/openfga/values.schema.json
  • charts/openfga/values.yaml
  • docs/adr/000-template.md
  • docs/adr/001-adopt-openfga-operator.md
  • docs/adr/002-operator-managed-migrations.md
  • docs/adr/003-declarative-store-lifecycle-crds.md
  • docs/adr/004-operator-deployment-model.md
  • docs/adr/README.md
  • operator/.dockerignore
  • operator/Dockerfile
  • operator/Makefile
  • operator/README.md
  • operator/cmd/main.go
  • operator/go.mod
  • operator/internal/controller/helpers.go
  • operator/internal/controller/migration_controller.go
  • operator/internal/controller/migration_controller_test.go
  • operator/tests/README.md
  • operator/tests/values-db-outage.yaml
  • operator/tests/values-happy-path.yaml
  • operator/tests/values-no-db.yaml

Comment thread .github/workflows/operator.yml
Comment thread charts/openfga-operator/templates/clusterrole.yaml Outdated
Comment thread charts/openfga-operator/templates/deployment.yaml
Comment thread charts/openfga-operator/templates/NOTES.txt Outdated
Comment thread charts/openfga/templates/_helpers.tpl
Comment thread operator/internal/controller/migration_controller.go Outdated
Comment thread operator/internal/controller/migration_controller.go Outdated
Comment thread operator/internal/controller/migration_controller.go Outdated
Comment thread operator/Makefile
Comment thread operator/README.md Outdated
@emilic
emilic marked this pull request as draft April 10, 2026 17:45
emilic added a commit that referenced this pull request Apr 11, 2026
- Harden pod security (runAsNonRoot, seccompProfile, drop ALL caps)
- Find container by name instead of index to handle sidecars
- Skip migration for memory datastore
- Persist retry-after annotation before Job deletion to survive re-enqueue
- Clear MigrationFailed condition on success
- Propagate imagePullSecrets and securityContext to migration Jobs
- Remove unused RBAC rules (secrets, serviceaccounts)
- Add POD_NAMESPACE downward API for namespace-scoped watch default
- Remove no-op migration values (timeout, backoffLimit, resources)
- Fix migration SA helper to require name when create=false
- Guard operator logic on both operator.enabled and migration.enabled
- Build and load operator image into kind for chart-testing CI
- Add path filters to operator workflow
- Fix ADR inaccuracies (retry strategy, default-enabled wording)
- Pin Dockerfile base image to golang:1.26.2
@emilic

emilic commented Apr 11, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 11, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

emilic added a commit that referenced this pull request Apr 11, 2026
- Harden pod security (runAsNonRoot, seccompProfile, drop ALL caps)
- Find container by name instead of index to handle sidecars
- Skip migration for memory datastore
- Persist retry-after annotation before Job deletion to survive re-enqueue
- Clear MigrationFailed condition on success
- Propagate imagePullSecrets and securityContext to migration Jobs
- Remove unused RBAC rules (secrets, serviceaccounts)
- Add POD_NAMESPACE downward API for namespace-scoped watch default
- Remove no-op migration values (timeout, backoffLimit, resources)
- Fix migration SA helper to require name when create=false
- Guard operator logic on both operator.enabled and migration.enabled
- Build and load operator image into kind for chart-testing CI
- Add path filters to operator workflow
- Fix ADR inaccuracies (retry strategy, default-enabled wording)
- Pin Dockerfile base image to golang:1.26.2
@emilic
emilic force-pushed the feat/operator-migration branch from 24897df to f149c60 Compare April 11, 2026 12:37
@emilic

emilic commented Apr 11, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Apr 11, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@emilic

emilic commented Apr 11, 2026

Copy link
Copy Markdown
Contributor Author

@copilot review

@emilic
emilic requested a review from Copilot April 11, 2026 12:39
@emilic
emilic marked this pull request as ready for review April 11, 2026 12:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 38 out of 40 changed files in this pull request and generated 5 comments.

Comments suppressed due to low confidence (1)

charts/openfga/templates/deployment.yaml:65

  • When operator.enabled=true but migration.enabled=false, the chart disables the hook-based migration Job (templates/job.yaml) and RBAC (templates/rbac.yaml), but the Deployment can still render the legacy wait-for-migration initContainer because $operatorMigration := and operator.enabled migration.enabled evaluates to false. With the default datastore.migrationType: job + waitForMigrations: true, this will deadlock (initContainer waits for a Job that the chart no longer creates). Consider changing the initContainer gating to disable legacy migration orchestration whenever operator.enabled is true (or explicitly fail fast for the unsupported combination).
      {{- $operatorMigration := and .Values.operator.enabled .Values.migration.enabled }}
      {{ if or (and (not $operatorMigration) (or (and (has .Values.datastore.engine (list "postgres" "mysql")) .Values.datastore.applyMigrations .Values.datastore.waitForMigrations))) .Values.extraInitContainers }}
      initContainers:
        {{- if not $operatorMigration }}
        {{- if and (has .Values.datastore.engine (list "postgres" "mysql")) .Values.datastore.applyMigrations .Values.datastore.waitForMigrations (eq .Values.datastore.migrationType "job") }}
        - name: wait-for-migration
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          image: "{{ .Values.initContainer.repository }}:{{ .Values.initContainer.tag }}"
          imagePullPolicy: {{ .Values.initContainer.pullPolicy }}
          args: ["job-wr", '{{ include "openfga.fullname" . }}-migrate']
          resources:
            {{- toYaml .Values.datastore.migrations.resources | nindent 12 }}
        {{- end }}
        {{- if and (has .Values.datastore.engine (list "postgres" "mysql")) (eq .Values.datastore.migrationType "initContainer") }}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread operator/internal/controller/helpers.go
Comment thread operator/README.md Outdated
Comment thread charts/openfga/templates/serviceaccount.yaml Outdated
Comment thread charts/openfga-operator/templates/role.yaml
Comment thread operator/internal/controller/migration_controller.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (7)
operator/README.md (1)

90-102: Add language specifier to the project structure block.

The directory tree is missing a language identifier. Add text after the opening backticks to resolve the markdownlint warning.

📝 Proposed fix
-```
+```text
 operator/
 ├── cmd/
 │   └── main.go                          # Entry point, manager setup
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@operator/README.md` around lines 90 - 102, Update the fenced code block in
operator/README.md that contains the project tree (the block starting with
"operator/" and listing cmd/, internal/, Dockerfile, etc.) to include a language
specifier by changing the opening ``` to ```text so the block becomes a
text-formatted code fence; this will resolve the markdownlint warning without
altering the block contents.
docs/adr/004-operator-deployment-model.md (1)

69-90: Add language specifier to this fenced code block.

The directory structure block is missing a language identifier, which triggers the markdownlint warning. Add text after the opening backticks.

📝 Proposed fix
-```
+```text
 helm-charts/
 ├── charts/
 │   ├── openfga/                    # Main chart (existing)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/adr/004-operator-deployment-model.md` around lines 69 - 90, The fenced
code block showing the Helm chart directory structure lacks a language
specifier; update that block by adding "text" immediately after the opening
triple backticks (i.e., change ``` to ```text) so the directory tree is treated
as plain text and markdownlint warning is resolved (refer to the directory
structure block containing "helm-charts/" and the nested "openfga" /
"openfga-operator" entries).
operator/cmd/main.go (1)

31-43: Consider validating that Job configuration flags are non-negative.

The flags --backoff-limit, --active-deadline-seconds, and --ttl-seconds-after-finished are parsed as int but later cast to int32/int64. Negative values would produce unexpected behavior. The Kubernetes API would reject invalid values, but an early validation with a clear error message would improve the operator experience.

🛡️ Suggested validation after flag.Parse()
 	flag.Parse()

+	if backoffLimit < 0 || activeDeadline < 0 || ttlAfterFinished < 0 {
+		fmt.Fprintln(os.Stderr, "error: job configuration flags must be non-negative")
+		os.Exit(1)
+	}
+
 	ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts)))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@operator/cmd/main.go` around lines 31 - 43, After parsing flags, validate the
three Job-related flag variables backoffLimit, activeDeadline, and
ttlAfterFinished are non-negative; in main.go (after flag.Parse()) add checks
that return/log a clear fatal error if any value < 0 (e.g., using log.Fatalf or
klog.Fatalf) so the operator exits with a descriptive message instead of
proceeding to cast to int32/int64 and creating invalid Kubernetes Job specs;
reference the flag variables backoffLimit, activeDeadline, ttlAfterFinished and
the controller.Default* constants when adding the validation.
operator/internal/controller/migration_controller.go (1)

79-90: Status update error handling could be improved.

When clearing the MigrationFailed condition, errors from r.Status().Update() are logged but execution continues. If this update fails due to a conflict, the condition remains stale. Consider returning a requeue to ensure the condition is eventually cleared.

♻️ Suggested improvement
 	if currentVersion == desiredVersion {
 		logger.V(1).Info("migration up to date", "version", desiredVersion)
 		clearMigrationFailedCondition(deployment)
 		if patchErr := r.Status().Update(ctx, deployment); patchErr != nil {
-			logger.Error(patchErr, "failed to clear MigrationFailed condition")
+			if apierrors.IsConflict(patchErr) {
+				return ctrl.Result{Requeue: true}, nil
+			}
+			logger.Error(patchErr, "failed to clear MigrationFailed condition")
 		}
 		if _, scaleErr := ensureDeploymentScaled(ctx, r.Client, deployment); scaleErr != nil {

Apply the same pattern at lines 146-148.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@operator/internal/controller/migration_controller.go` around lines 79 - 90,
The status update that clears MigrationFailed via
clearMigrationFailedCondition(deployment) calls r.Status().Update(ctx,
deployment) but only logs errors and proceeds; change this so that if
r.Status().Update returns an error (e.g., a conflict) you return that error (or
return ctrl.Result{Requeue: true} with the error) instead of continuing,
mirroring the pattern used at lines 146-148; locate the block that checks
currentVersion == desiredVersion, handle patchErr from r.Status().Update(ctx,
deployment) by returning the error or a requeue result so the condition will be
retried before calling ensureDeploymentScaled and returning success.
operator/internal/controller/migration_controller_test.go (1)

166-231: Test lacks verification of MigrationFailed condition clearing.

The TestReconcile_JobSucceeded_UpdatesConfigMapAndScalesUp test verifies ConfigMap creation and Deployment scaling, but doesn't verify that a prior MigrationFailed condition gets cleared. Consider adding a test case where the Deployment starts with a MigrationFailed condition and verifying it's cleared after success.

🧪 Suggested additional test case
func TestReconcile_JobSucceeded_ClearsMigrationFailedCondition(t *testing.T) {
	// Given: a Deployment with a MigrationFailed condition and a succeeded Job.
	dep := newTestDeployment("openfga", "default", "openfga/openfga:v1.14.0", 0)
	dep.Annotations[AnnotationDesiredReplicas] = "3"
	dep.Status.Conditions = []appsv1.DeploymentCondition{
		{
			Type:   "MigrationFailed",
			Status: corev1.ConditionTrue,
			Reason: "MigrationJobFailed",
		},
	}

	job := &batchv1.Job{
		// ... same as existing test ...
		Status: batchv1.JobStatus{Succeeded: 1},
	}

	r := newReconciler(dep, job)
	_, err := r.Reconcile(context.Background(), ctrl.Request{
		NamespacedName: types.NamespacedName{Name: "openfga", Namespace: "default"},
	})
	if err != nil {
		t.Fatalf("unexpected error: %v", err)
	}

	// Verify MigrationFailed condition is cleared.
	updated := &appsv1.Deployment{}
	_ = r.Get(context.Background(), types.NamespacedName{Name: "openfga", Namespace: "default"}, updated)
	for _, c := range updated.Status.Conditions {
		if c.Type == "MigrationFailed" && c.Status == corev1.ConditionTrue {
			t.Error("expected MigrationFailed condition to be cleared")
		}
	}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@operator/internal/controller/migration_controller_test.go` around lines 166 -
231, Add a test that ensures a pre-existing MigrationFailed condition on the
Deployment is removed after a successful Job: create a Deployment via
newTestDeployment, set dep.Annotations[AnnotationDesiredReplicas] and seed
dep.Status.Conditions with a DeploymentCondition having Type "MigrationFailed"
and Status corev1.ConditionTrue, create a succeeded batchv1.Job as in the
existing TestReconcile_JobSucceeded_UpdatesConfigMapAndScalesUp, call
newReconciler(...).Reconcile(...), then Get the updated Deployment and assert
that no Status.Condition remains with Type "MigrationFailed" && Status
corev1.ConditionTrue; name the new test
TestReconcile_JobSucceeded_ClearsMigrationFailedCondition and reuse the same
Job/Reconiler setup symbols.
operator/internal/controller/helpers.go (1)

258-285: Scaling logic has a subtle edge case when replicas annotation already exists.

If the AnnotationDesiredReplicas annotation already exists (e.g., from a previous scale-down), the function correctly avoids overwriting it (line 272). However, if the annotation contains "0" from a previous interrupted reconciliation where the Deployment was manually set to 0 replicas before the operator took over, ensureDeploymentScaled would restore to 0 replicas.

This is documented behavior per the ADR ("Deployment starts at replicas: 0"), but consider logging a warning when the stored desired replicas is 0 to aid debugging.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@operator/internal/controller/helpers.go` around lines 258 - 285, In
scaleDeploymentToZero, detect when
deployment.Annotations[AnnotationDesiredReplicas] already exists and equals "0"
and emit a warning with the Deployment identity to aid debugging; update the
function (around the annotation-check block) to check the stored value string ==
"0" and call your controller logger (e.g., klog.Warningf or
ctrl.Log.WithValues("deployment", deployment.Name, "namespace",
deployment.Namespace).V(0).Info/Warning) to record that the saved desired
replicas is zero before returning/continuing, leaving the existing behavior of
not overwriting the annotation intact.
docs/adr/002-operator-managed-migrations.md (1)

76-94: Consider adding language specifiers to code fences.

The ASCII diagrams and sequence blocks lack language specifiers. Adding text or plaintext as the language would satisfy markdown linters and improve rendering consistency across platforms.

📝 Suggested fix
-```
+```text
 ┌────────────────────────────────────────────────────────┐
 │                  Operator Reconciliation                │

Apply similarly to the code fences at lines 130 and 147.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/adr/002-operator-managed-migrations.md` around lines 76 - 94, The ASCII
diagrams and sequence blocks (for example the block starting with "Operator
Reconciliation" and the other two visual blocks later in the file) are missing
Markdown language specifiers; update each triple-backtick fence that contains
ASCII art/sequence steps to use a plain text specifier (e.g., ```text or
```plaintext) so markdown linters/renderers handle them consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@docs/adr/002-operator-managed-migrations.md`:
- Around line 76-94: The ASCII diagrams and sequence blocks (for example the
block starting with "Operator Reconciliation" and the other two visual blocks
later in the file) are missing Markdown language specifiers; update each
triple-backtick fence that contains ASCII art/sequence steps to use a plain text
specifier (e.g., ```text or ```plaintext) so markdown linters/renderers handle
them consistently.

In `@docs/adr/004-operator-deployment-model.md`:
- Around line 69-90: The fenced code block showing the Helm chart directory
structure lacks a language specifier; update that block by adding "text"
immediately after the opening triple backticks (i.e., change ``` to ```text) so
the directory tree is treated as plain text and markdownlint warning is resolved
(refer to the directory structure block containing "helm-charts/" and the nested
"openfga" / "openfga-operator" entries).

In `@operator/cmd/main.go`:
- Around line 31-43: After parsing flags, validate the three Job-related flag
variables backoffLimit, activeDeadline, and ttlAfterFinished are non-negative;
in main.go (after flag.Parse()) add checks that return/log a clear fatal error
if any value < 0 (e.g., using log.Fatalf or klog.Fatalf) so the operator exits
with a descriptive message instead of proceeding to cast to int32/int64 and
creating invalid Kubernetes Job specs; reference the flag variables
backoffLimit, activeDeadline, ttlAfterFinished and the controller.Default*
constants when adding the validation.

In `@operator/internal/controller/helpers.go`:
- Around line 258-285: In scaleDeploymentToZero, detect when
deployment.Annotations[AnnotationDesiredReplicas] already exists and equals "0"
and emit a warning with the Deployment identity to aid debugging; update the
function (around the annotation-check block) to check the stored value string ==
"0" and call your controller logger (e.g., klog.Warningf or
ctrl.Log.WithValues("deployment", deployment.Name, "namespace",
deployment.Namespace).V(0).Info/Warning) to record that the saved desired
replicas is zero before returning/continuing, leaving the existing behavior of
not overwriting the annotation intact.

In `@operator/internal/controller/migration_controller_test.go`:
- Around line 166-231: Add a test that ensures a pre-existing MigrationFailed
condition on the Deployment is removed after a successful Job: create a
Deployment via newTestDeployment, set dep.Annotations[AnnotationDesiredReplicas]
and seed dep.Status.Conditions with a DeploymentCondition having Type
"MigrationFailed" and Status corev1.ConditionTrue, create a succeeded
batchv1.Job as in the existing
TestReconcile_JobSucceeded_UpdatesConfigMapAndScalesUp, call
newReconciler(...).Reconcile(...), then Get the updated Deployment and assert
that no Status.Condition remains with Type "MigrationFailed" && Status
corev1.ConditionTrue; name the new test
TestReconcile_JobSucceeded_ClearsMigrationFailedCondition and reuse the same
Job/Reconiler setup symbols.

In `@operator/internal/controller/migration_controller.go`:
- Around line 79-90: The status update that clears MigrationFailed via
clearMigrationFailedCondition(deployment) calls r.Status().Update(ctx,
deployment) but only logs errors and proceeds; change this so that if
r.Status().Update returns an error (e.g., a conflict) you return that error (or
return ctrl.Result{Requeue: true} with the error) instead of continuing,
mirroring the pattern used at lines 146-148; locate the block that checks
currentVersion == desiredVersion, handle patchErr from r.Status().Update(ctx,
deployment) by returning the error or a requeue result so the condition will be
retried before calling ensureDeploymentScaled and returning success.

In `@operator/README.md`:
- Around line 90-102: Update the fenced code block in operator/README.md that
contains the project tree (the block starting with "operator/" and listing cmd/,
internal/, Dockerfile, etc.) to include a language specifier by changing the
opening ``` to ```text so the block becomes a text-formatted code fence; this
will resolve the markdownlint warning without altering the block contents.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5c414505-2633-4683-95be-cd73ce829f78

📥 Commits

Reviewing files that changed from the base of the PR and between 7684485 and f149c60.

⛔ Files ignored due to path filters (2)
  • charts/openfga/Chart.lock is excluded by !**/*.lock
  • operator/go.sum is excluded by !**/*.sum
📒 Files selected for processing (38)
  • .github/workflows/operator.yml
  • .github/workflows/test.yml
  • charts/openfga-operator/Chart.yaml
  • charts/openfga-operator/crds/README.md
  • charts/openfga-operator/templates/NOTES.txt
  • charts/openfga-operator/templates/_helpers.tpl
  • charts/openfga-operator/templates/clusterrole.yaml
  • charts/openfga-operator/templates/clusterrolebinding.yaml
  • charts/openfga-operator/templates/deployment.yaml
  • charts/openfga-operator/templates/serviceaccount.yaml
  • charts/openfga-operator/values.yaml
  • charts/openfga/Chart.yaml
  • charts/openfga/templates/_helpers.tpl
  • charts/openfga/templates/deployment.yaml
  • charts/openfga/templates/job.yaml
  • charts/openfga/templates/rbac.yaml
  • charts/openfga/templates/serviceaccount.yaml
  • charts/openfga/values.schema.json
  • charts/openfga/values.yaml
  • docs/adr/000-template.md
  • docs/adr/001-adopt-openfga-operator.md
  • docs/adr/002-operator-managed-migrations.md
  • docs/adr/003-declarative-store-lifecycle-crds.md
  • docs/adr/004-operator-deployment-model.md
  • docs/adr/README.md
  • operator/.dockerignore
  • operator/Dockerfile
  • operator/Makefile
  • operator/README.md
  • operator/cmd/main.go
  • operator/go.mod
  • operator/internal/controller/helpers.go
  • operator/internal/controller/migration_controller.go
  • operator/internal/controller/migration_controller_test.go
  • operator/tests/README.md
  • operator/tests/values-db-outage.yaml
  • operator/tests/values-happy-path.yaml
  • operator/tests/values-no-db.yaml
✅ Files skipped from review due to trivial changes (11)
  • operator/.dockerignore
  • charts/openfga-operator/crds/README.md
  • charts/openfga/Chart.yaml
  • operator/go.mod
  • operator/Dockerfile
  • charts/openfga-operator/templates/NOTES.txt
  • docs/adr/001-adopt-openfga-operator.md
  • charts/openfga-operator/Chart.yaml
  • charts/openfga-operator/templates/_helpers.tpl
  • charts/openfga/templates/_helpers.tpl
  • charts/openfga-operator/values.yaml
🚧 Files skipped from review as they are similar to previous changes (4)
  • charts/openfga/templates/serviceaccount.yaml
  • charts/openfga/values.schema.json
  • .github/workflows/operator.yml
  • charts/openfga/templates/deployment.yaml

govulncheck reported 12 vulnerabilities reachable from the operator: standard
library issues in net/http, net/url, crypto/tls, crypto/x509 and encoding/asn1
fixed by Go 1.26.6, plus golang.org/x/net (fixed in v0.55.0) and
golang.org/x/text (fixed in v0.39.0). It reports none after this change.
Pin the actions in operator.yml to commit SHAs like the other workflows (#344),
reusing the checkout and login-action versions already pinned there. Add gomod
and docker entries for operator/ so the module and base images get updates.
The operator scaled the Deployment to an openfga.dev/desired-replicas
annotation and the chart omitted spec.replicas, which broke in practice:

- Switching an existing release to operator mode removed spec.replicas, so
  Helm's three-way merge and server-side apply both reset the Deployment to
  one replica until the migration finished.
- kubectl scale was reverted immediately and HPAs could not be used.
- The readiness gate it relied on only holds pods back below schema revision
  4, so on upgrades new pods already served before the migration ran.

The chart now renders replicas as in legacy mode and the operator only runs
migration Jobs. Along with that:

- Trust a migration Job only by its openfga.dev/desired-version annotation.
  The label fallback accepted the legacy Helm hook Job, whose version label
  is the chart appVersion, and skipped migration 006 on an upgrade from v1.9.5.
- Keep a failed Job for 60s and then replace it, instead of tracking the delay
  in a retry-after annotation on the Deployment.
- Leave activeDeadlineSeconds unset by default, as the hook Job does, so long
  index builds and table rebuilds are not killed halfway. A Job whose pod
  cannot start is rebuilt instead once the Deployment's pod template changes,
  tracked through an openfga.dev/pod-template-hash annotation.
- Only opt the Deployment in, and create the migration service account, for
  Postgres and MySQL, which lets the operator drop its memory engine check.
github.com/openfga/openfga-operator does not exist; the module is at
github.com/openfga/helm-charts/operator, so make the path match.
Read the event name and ref from the environment instead of inlining
expressions in the script, and drop an unused loop variable.
…ion.enabled

The operator was spread over three top-level values: operator.enabled,
openfga-operator (subchart values) and migration.enabled. Use
openfga-operator.enabled as the dependency condition, which is Helm's
convention and how this chart already toggles postgresql and mysql, so the
operator's toggle and configuration live under one key.

migration.enabled duplicated datastore.applyMigrations: both meant "do not run
migrations from this release", and the helper required both. Keep only
applyMigrations. migration.serviceAccount stays, since the parent chart
creates that service account.
Add a section on operator-run migrations to the openfga chart README and list
the openfga-operator chart in the repository README, since chart-releaser
publishes it. Note that the migration pod does not carry the OpenFGA pod
labels, which matters for NetworkPolicies. Make the ADR index match ADR-001's
status, drop the claim that the operator was scaffolded with kubebuilder, and
remove the empty crds/ placeholder directory from the operator chart.
- Run the kind, ct install and E2E steps when operator/ changes, not only
  when a chart changes; operator code was otherwise never tested against a
  cluster on its own.
- Fail operator PRs that change the image inputs without bumping the operator
  chart's appVersion and version, since CI publishes each version tag once.
  Document the full release chain in the operator README.
- Upgrade to the chart's appVersion in the E2E instead of a pinned v1.14.1,
  so every OpenFGA version bump exercises migrating to it.
- Run the operator's unit tests with -race.
Comment thread operator/internal/controller/migration_controller.go Outdated
Comment thread operator/internal/controller/migration_controller.go
Comment thread operator/internal/controller/helpers.go Outdated
A version change replaced the migration Job at once, even with its pod
running. Aborting a non-transactional step such as the concurrent index build
in Postgres migration 006 leaves an invalid index, and the rerun's IF NOT
EXISTS then skips it while goose records the version as applied. Wait for a
running Job to finish and replace it afterwards, as already done for pod
template changes.
Comment thread operator/internal/controller/migration_controller.go Outdated
Comment thread operator/internal/controller/helpers.go Outdated
Comment thread .github/workflows/operator.yml Outdated
Siddhant-K-code and others added 5 commits September 23, 2026 16:24
Track the complete migration Job identity, preserve migration-specific pod configuration, and prevent unsafe resource replacement. Harden retry cleanup and require parent chart release version propagation.
…e datastore too

Review findings on the migration Job:

- Sidecars and init containers: the Job now runs the Deployment's other
  containers as native sidecars (restartPolicy: Always, so they stop when
  migrate exits) and copies its init containers, so a database proxy on
  localhost works. Needs Kubernetes 1.29 when sidecars are present.
- migrate.labels and migrate.annotations are forwarded through the
  openfga.dev/migration-labels and -annotations Deployment annotations, with
  helm.sh/* keys dropped, so Istio/Linkerd injection can be disabled for the
  migration pod. The operator's identity labels and openfga.dev/* annotations
  cannot be overridden.
- The migration identity is the image tag plus an openfga.dev/migration-trigger
  annotation the chart derives from the datastore settings and the new
  migration.trigger value, so pointing the release at another database with
  the same image runs the migration, and a rotated Secret can be handled
  declaratively instead of by deleting the status ConfigMap.
- Only Jobs the operator created or the legacy hook Job are replaced; a
  same-name Job from elsewhere is left alone and reported with a
  MigrationJobConflict event. A status ConfigMap not owned by the Deployment
  is not trusted. Deletion uses foreground propagation with UID and
  resourceVersion preconditions so two migrations never overlap.
- ttlSecondsAfterFinished is applied after success only. Set at creation, a
  TTL below the retry delay removed failed Jobs before the retry, and a TTL of
  0 could remove a completed Job before its outcome was recorded.
- Events on the Deployment for started, succeeded, failed and conflicting
  migrations.

The release guard moves to .github/scripts/check-operator-release.sh and also
requires the openfga chart version and its operator dependency to move.
Comment thread operator/internal/controller/migration_controller.go
Comment thread .github/scripts/check-operator-release.sh Outdated
Comment thread .github/workflows/operator.yml
@Siddhant-K-code

This comment was marked as resolved.

Siddhant-K-code and others added 2 commits September 23, 2026 17:32
…tity

Three regressions from ca25836 and 3bc8ab5, each reproduced on a cluster:

- A Job whose pod exists but cannot start (CreateContainerConfigError,
  ImagePullBackOff, unschedulable) counted as started, so it was never
  rebuilt after the Deployment was fixed. Only a Ready or already
  succeeded pod counts as started.
- The datastore URI was dropped from the migration trigger, so pointing
  a release at another database with the same image ran no migration and
  left the new pods NotReady. The URI is back in the trigger with the
  credentials removed, so a password change does not count and no secret
  goes into the hash.
- The pod template hash was part of the recorded identity, so every pod
  change such as a log level ran a migration. The identity is the image
  and the trigger again; the hash only decides whether a Job that has not
  started is rebuilt.
@SoulPancake

Copy link
Copy Markdown
Member

Three regressions in the latest pushes, fixed in a7e467c:

  • 3bc8ab5: started counts Active > 0, so a Job stuck in CreateContainerConfigError / ImagePullBackOff is never rebuilt after the Deployment is fixed.
  • ca25836: the URI was dropped from the trigger. It's in a Secret with a fixed name, so repointing datastore.uri at another database with the same image runs no migration.
  • ca25836: the pod template hash is in the recorded identity, so any pod change (e.g. log.level) runs a migration.

release.yml waited up to 20 minutes for ghcr.io/openfga/openfga-operator:<appVersion>
before running chart-releaser. If the operator build failed on main, every chart in
that push stayed unreleased until another merge, since workflow_dispatch never
publishes the :<appVersion> tag.

The chart already treats the openfga image this way: no registry check, the PR
E2E installs it. test.yml builds and loads the operator image into kind, so a PR
is proven installable before merge; the remaining window is the few minutes
between chart publish and image push on the same commit, which kubelet retries
through on its own. release.yml is back to its state on main.
…app service account by default

The Helm hook Job ran as the OpenFGA service account. Creating a separate
<release>-migration service account by default meant a release on IRSA or
Workload Identity lost its IAM role on the migration Job when switching to the
operator. The operator already falls back to the pod's service account when the
annotation is absent, so migration.serviceAccount.create now defaults to false
and the dedicated account is opt-in.

rbac.create gates the operator's Role and RoleBinding, as the Helm RBAC
guidelines recommend. The operator chart notes no longer warn about an
unpublished image.
…ty scope

The image build now attaches an SBOM and build provenance and signs the digest
with cosign keyless, then verifies the signature in the same job, the way
openfga/openfga releases do.

controller-runtime served /metrics on 8080 to any pod in the cluster with no
declared port and no authentication. The chart now passes
--metrics-bind-address=0 unless metrics.enabled is set, which also declares a
named container port.

The operator README gets a Security section: namespace scope, the Role's verbs,
ports, how to verify the image signature and where to report issues. The
operator chart declares the Artifact Hub operator, capability and signing key
annotations that the openfga chart already carries.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

6 participants