Kubernetes Operator

forail-operator manages Forail resources as Kubernetes custom resources. You declare an Organization, Project, Inventory, Credential, JobTemplate, Schedule, Team or Workflow in YAML, and the operator reconciles it against a Forail backend over the REST API — creating what is missing, updating what drifted, and reporting the outcome in .status.

It is the same object model you would build by hand in the UI, expressed as GitOps-friendly manifests.

Install

TOKEN=$(kubectl -n forail exec deploy/forail-web -- \
    forail-manage create_oauth2_token --user admin | tail -1)

helm install forail-operator ./helm -n forail-operator --create-namespace \
    --set forail.url=http://forail-web.forail.svc.cluster.local:8013 \
    --set forail.token="$TOKEN"

forail.url has to be a host Forail accepts. The chart's forail.allowedHosts already covers the forail-web Service DNS names, so the URL above works unchanged. Reaching Forail under any other name — an ingress host, an external load balancer — needs --set forail.hostHeader=<that host>, or Django rejects every call with 400.

For OpenShift / OperatorHub there is an OLM bundle under bundle/; see the repository README for the catalog build.

Resource model

Nine kinds, all in API group forail.forail-platform.io/v1alpha1:

Kind Forail resource
Organization /api/v2/organizations
Team /api/v2/teams + /teams/{id}/users/
Project /api/v2/projects
Inventory /api/v2/inventories
Credential /api/v2/credentials
JobTemplate /api/v2/job_templates
Schedule /api/v2/schedules
Workflow /api/v2/workflow_job_templates + /workflow_nodes/
ForailInstance control-plane only — see Multiple backends

Every reconciler writes Synced and Ready conditions plus the upstream id, so kubectl describe tells you whether Forail actually accepted the object:

status:
  conditions:
    - type: Synced
      status: "True"
      reason: InSync
      message: Organization is in sync with Forail
  forailId: 16

Credential Secrets and namespaces

A Credential keeps its sensitive fields in a Kubernetes Secret and references them from spec.inputsFrom:

apiVersion: forail.forail-platform.io/v1alpha1
kind: Credential
metadata:
  name: deploy-key
  namespace: forail-operator
spec:
  organization: Default
  credentialType: Machine
  inputs:
    username: deploy
  inputsFrom:
    - name: ssh_key_data
      valueFrom:
        name: deploy-ssh
        key: ssh_key_data

The operator never holds a cluster-wide grant on Secrets. Its access is a namespaced Role, and its Secret cache is scoped to the same set — deliberately, because a controller that can read every Secret in the cluster is a large blast radius for a convenience nobody asked for.

The practical consequence: a Credential resolves spec.inputsFrom in its own namespace, and that namespace has to be one the operator was given. Out of the box that is only the release namespace, so Credentials live beside the operator. To keep them elsewhere, name those namespaces at install time:

helm install forail-operator ./helm ... \
    --set 'secretNamespaces={team-a,team-b}'

That single value renders both halves — the Secret Role/RoleBinding in each namespace, and --secret-namespaces on the Deployment so the cache covers the same set. Adding a RoleBinding by hand does not work on its own; the cache would still reject the namespace with:

read Secret team-a/deploy-ssh: unable to get: team-a/deploy-ssh
  because of unknown namespace for the cache

Multiple backends

Every CR takes an optional spec.forailInstance. When set, the controller looks up a ForailInstance by that name in the same namespace, reads its bearer token from the Secret named by spec.tokenSecretRef, and builds a per-instance client (cached, invalidated when the observed generation moves). When it is empty the CR falls back to the default backend from --forail-url / --forail-token.

That is what lets one operator drive several Forail installations:

---
apiVersion: v1
kind: Secret
metadata: { name: forail-eu-token, namespace: default }
stringData:
  token: <PAT from forail-manage create_oauth2_token>
---
apiVersion: forail.forail-platform.io/v1alpha1
kind: ForailInstance
metadata: { name: forail-eu, namespace: default }
spec:
  url: https://forail-eu.example.com
  tokenSecretRef: { name: forail-eu-token, key: token }
---
apiVersion: forail.forail-platform.io/v1alpha1
kind: Project
metadata: { name: eu-roles, namespace: default }
spec:
  forailInstance: forail-eu       # routed to the EU backend
  organization: Default
  scmType: git
  scmUrl: https://github.com/mycorp/eu-roles.git
---
apiVersion: forail.forail-platform.io/v1alpha1
kind: Project
metadata: { name: us-roles, namespace: default }
spec:
  # no forailInstance — uses the default backend
  organization: Default
  scmType: git
  scmUrl: https://github.com/mycorp/us-roles.git

The ForailInstance reconciler polls /api/v2/ping/ every 60 seconds (30 on failure) and surfaces reachability and server version, so a backend going dark is visible without digging:

$ kubectl get forailinstance -A
NAMESPACE  NAME        URL                             REACHABLE  VERSION     LAST CHECKED
default    forail-eu   https://forail-eu.example.com   true       2026.07.0   12s
default    forail-us   https://forail-us.example.com   false                 22s

Remember that tokenSecretRef is subject to the same namespace rule as Credential Secrets above.

Workflows

Workflow is the only kind with a graph model: spec.nodes[] is a DAG of job-template nodes joined by success, failure and always edges, keyed by a per-node identifier. The reconciler creates the workflow job template first, then the nodes, then the edges between them, so a partially applied graph converges on the next pass rather than erroring.

Verifying an install

kubectl -n forail-operator get pods                 # 1/1 Running, 0 restarts
kubectl -n forail-operator logs deploy/forail-operator-forail-operator | head

kubectl apply -f config/samples/organization-sample.yaml
kubectl get organization platform-team -o jsonpath='{.status}'
# -> conditions Synced/Ready True, forailId set

If a CR stays without status, check that the operator can reach the backend (kubectl get forailinstance -A, or the operator log for 400/401), and that any referenced Secret is in a namespace the operator was given.

Versions

The operator moved to the platform's date-based scheme; v1.0.x predates it.

Version Notable
2026.07.1 secretNamespaces — Credential Secrets outside the operator's namespace
2026.07.0 Secret access narrowed from cluster-wide to a namespaced Role
2026.06.0 Date-based versioning
v1.0.0 Nine CRDs, multi-cluster routing, OLM bundle