Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@ and the project uses SemVer.
now a namespaced `Role`/`RoleBinding` in the operator's own namespace (kustomize
+ Helm), driven by a namespaced `+kubebuilder:rbac` marker.

### Fixed
- **Operator no longer crash-loops under the scoped Secret role.** The credential
reconciler watches Secrets, and the manager cached them at *cluster* scope, so
the informer's list was refused by the namespaced role, the shared cache never
reported synced, and the manager exited with
`failed to wait for ... caches to sync` — taking all nine controllers down, not
just Credential. The manager now caches Secrets only in its own namespace,
resolved from the `POD_NAMESPACE` downward-API env (added to the Helm
deployment and the kustomize manager manifest) and falling back to the mounted
ServiceAccount namespace file. Out-of-cluster runs, where neither is present,
keep the unscoped cache and log why.

## [2026.06.1] - 2026-06-20

### Changed
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ helm install forail-operator ./helm -n forail-operator --create-namespace \
--set forail.token=$TOKEN
```

Two namespace notes:

- **Secrets referenced by `Credential` CRs must live in the operator's own
namespace.** The operator holds a namespaced Secret `Role` (not a ClusterRole)
and caches Secrets only there, so a `spec.inputsFrom` pointing at a Secret in
another namespace will not resolve.
- **`forail.url` must be a host Forail accepts.** The chart's
`forail.allowedHosts` covers the `forail-web` Service DNS names, so the URL
above works as-is. Reaching Forail under any other name — an ingress host, an
external load balancer — means passing `--set forail.hostHeader=<that host>`,
otherwise Django rejects every call with `400`.

### Via OLM (recommended for OpenShift / OperatorHub)

```bash
Expand Down
38 changes: 38 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ package main
import (
"flag"
"os"
"strings"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/runtime"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/cache"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/healthz"
"sigs.k8s.io/controller-runtime/pkg/log/zap"
metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server"
Expand Down Expand Up @@ -54,12 +58,31 @@ func main() {
setupLog.Info("no default Forail backend configured; CRs without spec.forailInstance will not reconcile until --forail-url and --forail-token (or FORAIL_URL / FORAIL_TOKEN env) are set")
}

// needtofix M12 follow-up: the credential reconciler watches Secrets, and the
// manager's default cache watches them at CLUSTER scope. Since M12 narrowed the
// Secret grant to a namespaced Role, that informer can never list — it fails
// permanently, the shared cache never reports synced, and the manager exits
// ("failed to wait for ... caches to sync"), taking every controller down with
// it. Scope the Secret cache to the operator's own namespace so the informer
// asks only for what the Role actually grants.
cacheOpts := cache.Options{}
if ns := operatorNamespace(); ns != "" {
cacheOpts.ByObject = map[client.Object]cache.ByObject{
&corev1.Secret{}: {Namespaces: map[string]cache.Config{ns: {}}},
}
} else {
// Out-of-cluster runs (`make run`) use a kubeconfig that is normally
// cluster-admin, so an unscoped Secret cache is correct there.
setupLog.Info("operator namespace unknown (no POD_NAMESPACE env, no service-account namespace file); caching Secrets cluster-wide")
}

mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
Scheme: scheme,
Metrics: metricsserver.Options{BindAddress: metricsAddr},
HealthProbeBindAddress: probeAddr,
LeaderElection: enableLeaderElection,
LeaderElectionID: "forail-operator-leader.forail.forail-platform.io",
Cache: cacheOpts,
})
if err != nil {
setupLog.Error(err, "unable to start manager")
Expand Down Expand Up @@ -169,3 +192,18 @@ func main() {
os.Exit(1)
}
}

// operatorNamespace resolves the namespace the operator runs in: the
// POD_NAMESPACE downward-API env the chart sets, falling back to the namespace
// file every in-cluster ServiceAccount is mounted with (so a manifest that
// predates the env var still scopes correctly). Empty means out-of-cluster.
func operatorNamespace() string {
if ns := strings.TrimSpace(os.Getenv("POD_NAMESPACE")); ns != "" {
return ns
}
data, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
7 changes: 7 additions & 0 deletions config/manager/manager.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ spec:
- --metrics-bind-address=:8080
- --health-probe-bind-address=:8081
env:
# Scopes the manager's Secret cache to this namespace, matching the
# namespaced Secret Role (needtofix M12). Without it the cache would
# watch Secrets cluster-wide, get 403, and never sync.
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
# In-cluster service URL — bypasses the Ingress, plain HTTP on
# the pod network. No Host header needed because forail-web's
# internal nginx accepts any Host.
Expand Down
7 changes: 7 additions & 0 deletions helm/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,13 @@ spec:
- --leader-elect=true
{{- end }}
env:
# Scopes the manager's Secret cache to this namespace, matching the
# namespaced Secret Role (needtofix M12). Without it the cache would
# watch Secrets cluster-wide, get 403, and never sync.
- name: POD_NAMESPACE
valueFrom:
fieldRef:
fieldPath: metadata.namespace
- name: FORAIL_URL
value: {{ .Values.forail.url | quote }}
{{- if .Values.forail.hostHeader }}
Expand Down
Loading