FerrisKeyDocs

Operator

The operator manages FerrisKey instances through a custom resource. You describe a cluster in a FerrisKeyCluster manifest, and the operator creates the Kubernetes objects that run it and keeps them in line with the manifest.

The FerrisKeyCluster resource

The CRD is ferriskeyclusters.ferriskey.rs, API version ferriskey.rs/v1alpha1, namespaced, with the short name fkcl. kubectl get fkcl prints the version, the replica count, readiness, and the current phase.

apiVersion: ferriskey.rs/v1alpha1
kind: FerrisKeyCluster
metadata:
  name: my-ferriskey
  namespace: ferriskey
spec:
  name: my-ferriskey
  replicas: 3
  version: "0.10.0"
  api:
    apiUrl: "https://api.iam.yourorg.com"
    webappUrl: "https://iam.yourorg.com"
    allowedOrigins:
      - "https://iam.yourorg.com"
  database:
    secretRef:
      name: ferriskey-db-credentials

Spec reference

Every field below is required unless the description says otherwise.

Root

FieldTypeDescription
namestringRequired by the schema but not used to name anything. The generated objects are named after metadata.name, so set both to the same value
replicasintegerNumber of replicas. It sizes both the API and the console Deployments
versionstringImage tag of ferriskey-api and ferriskey-webapp. The migration Job runs the API image at the same tag
apiobjectAPI configuration
databaseobjectDatabase configuration

spec.api

FieldTypeDescription
apiUrlstringPublic URL of the API, as the browser reaches it. The console receives it as API_URL, and its origin is the only cross-origin address the console’s content security policy lets it call
webappUrlstringPublic URL of the console. The API receives it as WEBAPP_URL
allowedOriginsstring[]Optional. Browser origins allowed on every route, passed as ALLOWED_ORIGINS

The operator runs the API with SERVER_ROOT_PATH=/, so it is served from the root of a host, and in the example above the console has a host of its own. Per-client web origins only cover realm-scoped routes. /config, the health probes, and the API documentation carry no realm, so a console served from a different origin than the API has to keep its origin in allowedOrigins.

spec.database

FieldTypeDescription
secretRef.namestringSecret holding the database credentials, in the namespace of the FerrisKeyCluster
secretRef.namespacestringOptional. Accepted but ignored: pods can only read Secrets from their own namespace, and the operator always uses the namespace of the FerrisKeyCluster
databaseNamestringOptional. Accepted but not used. The database name comes from the dbname key of the Secret
sslModestringOptional. Accepted but not used. See the TLS note below

Database Secret

The Secret named by secretRef.name must carry these keys. A pod whose Secret lacks one does not start.

KeyUsed for
hostDATABASE_HOST on the API
portDATABASE_PORT on the API
dbnameDATABASE_NAME on the API
userDATABASE_USER on the API
passwordDATABASE_PASSWORD on the API
uriDATABASE_URL of the migration Job, a full connection URL such as postgresql://user:password@host:5432/dbname

The sslMode field does nothing. To make the migration Job use TLS, end the uri with ?sslmode=verify-full (or another mode). The API pods are different: the operator adds no environment variable to them beyond its fixed list, so they connect in the default prefer mode: TLS when the server offers it, without checking the certificate, and plain text when it does not. The production guide explains the modes.

Status

The operator writes the status subresource after every reconcile:

status:
  ready: true
  phase: Running
  message: Cluster successfully deployed
FieldDescription
readytrue once the operator has applied all the manifests. It does not wait for the pods, so check them with kubectl get pods
phaseRunning after a successful reconcile, Error after a failed one
messageCluster successfully deployed, or Error deploying cluster: followed by the cause

The CRD schema also defines conditions and databaseStatus, but the operator never fills them. After a failure it retries every 20 seconds. After a success it reconciles again every 60 seconds.

Deploying

Install the operator

The operator ships as its own Helm chart, which installs the CRD, the operator Deployment, and the RBAC it needs:

helm install ferriskey-operator oci://ghcr.io/ferriskey/charts/ferriskey-operator \
  --namespace ferriskey-system \
  --create-namespace

Create a database secret

Create the namespace the cluster will live in, then the Secret with the six keys:

kubectl create namespace ferriskey

kubectl create secret generic ferriskey-db-credentials \
  --namespace ferriskey \
  --from-literal=host=postgres.database.svc.cluster.local \
  --from-literal=port=5432 \
  --from-literal=dbname=ferriskey \
  --from-literal=user=ferriskey \
  --from-literal=password=your-db-password \
  --from-literal=uri=postgresql://ferriskey:your-db-password@postgres.database.svc.cluster.local:5432/ferriskey

Apply the FerrisKeyCluster manifest

kubectl apply -f ferriskey-cluster.yaml

Check status

kubectl get fkcl -n ferriskey
kubectl get pods -n ferriskey

What the operator creates

For a FerrisKeyCluster named <name>, the operator applies these objects in its namespace:

KindNameNotes
Secretferriskey-admin-<name>Random admin password
Jobferriskey-migrations-<name>Runs sqlx migrate run with backoffLimit: 3 and ttlSecondsAfterFinished: 300
Deploymentferriskey-api-<name>The API, on ghcr.io/ferriskey/ferriskey-api:<version>, port 3333
Serviceferriskey-api-<name>ClusterIP on port 3333
Deploymentferriskey-webapp-<name>The console, on ghcr.io/ferriskey/ferriskey-webapp:<version>, port 80, with API_URL set
Serviceferriskey-webapp-<name>ClusterIP on port 80

The operator applies the Job at every reconcile. Once Kubernetes has deleted the finished Job, the next pass creates it again, so a new migration pod appears every few minutes. Running the migrations again changes nothing when the schema is current.

No Ingress or HTTPRoute is created. Route your public hostnames to the two Services yourself.

The FerrisKeyCluster carries the finalizer ferriskey.rs/finalizer. Deleting it makes the operator delete the six objects above, so the operator has to be running for the deletion to finish. The database and the credentials Secret are not touched.

Limits of the operator today

The operator builds fixed pod specs, and the FerrisKeyCluster has no field to change them:

  • The API runs with ADMIN_USERNAME=admin, ADMIN_EMAIL=admin@gmail.com, ENV=production, LOG_FILTER=info, LOG_JSON=true, SERVER_PORT=3333 and SERVER_ROOT_PATH=/, plus WEBAPP_URL and ALLOWED_ORIGINS from the spec. There is no way to add other environment variables. That rules out SECRETS_ENCRYPTION_KEYS, so realm signing keys, TOTP seeds and SMTP passwords stay in clear in the database. It also rules out SERVER_PUBLIC_URL, which SAML needs, SERVER_TRUST_FORWARDED_FOR and the pool and Argon2 settings. Use the Helm chart if you need any of them.
  • Neither Deployment has liveness or readiness probes, resource requests or limits, or a security context.
  • replicas applies to the API and the console alike.
  • The ferriskey-admin-<name> Secret does not keep the admin password. The operator writes a new random value into it at every reconcile, which includes the 60-second requeue. The API reads ADMIN_PASSWORD when its container starts, and it only uses the value when the admin account has no password yet. After the first start, the Secret therefore no longer holds the password of the admin account, and a restart never changes that password.

Operator chart values

KeyDefaultDescription
crds.installtrueInstall the CRD with the chart
crds.keeptrueAnnotate the CRD with helm.sh/resource-policy: keep
rbac.createtrueCreate the RBAC objects. Requires operator.serviceAccount.create
operator.watchNamespaces[]Namespaces to watch. Empty watches the whole cluster
operator.replicas1Run a single replica
operator.image.repositoryghcr.io/ferriskey/ferriskey-operatorOperator image. The tag defaults to the chart’s appVersion
operator.resources64Mi memory request, 256Mi memory limitOperator container resources

Watching a subset of namespaces

By default the operator watches every namespace, and the chart creates a ClusterRole and a ClusterRoleBinding. The ClusterRole is narrow. It can get, list, watch, update and patch FerrisKeyCluster resources with their status and finalizers, and create, patch and delete Services, Secrets, Deployments and Jobs. Those rights hold in every namespace, so the operator can write Secrets anywhere in the cluster.

To confine it, list the namespaces:

operator:
  watchNamespaces:
    - team-a
    - team-b

With the list set, the chart drops the cluster-wide pair and creates one Role and one RoleBinding in each listed namespace instead. It also sets WATCH_NAMESPACES (comma-separated) on the operator Deployment. The operator then watches only those namespaces and ignores a FerrisKeyCluster created anywhere else.

The namespaces must exist when you install the chart, and each name has to be a valid DNS label: the operator refuses to start otherwise. Rendering fails on an empty entry, on a duplicate, and when common.env or operator.env define WATCH_NAMESPACES themselves.

CRD and RBAC ownership

By default the chart installs the CRD and annotates it with helm.sh/resource-policy: keep, so helm uninstall leaves it, and your clusters, alone. It also creates the RBAC described above.

If either is managed elsewhere, for example through GitOps or a dedicated CRD chart, turn the chart’s copy off:

crds:
  install: false

rbac:
  create: false

With rbac.create: false, you must grant the operator’s service account the same rules yourself.

Leave crds.keep at true in production. Deleting the CRD deletes every FerrisKeyCluster in the cluster along with it.