- Go 94.9%
- Makefile 3.2%
- Shell 1%
- Dockerfile 0.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .devcontainer | ||
| api/v1alpha1 | ||
| cmd | ||
| config | ||
| hack | ||
| internal/controller | ||
| test | ||
| .custom-gcl.yml | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitignore | ||
| .golangci.yml | ||
| AGENTS.md | ||
| CHANGELOG.md | ||
| ci-test.Dockerfile | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| Makefile | ||
| operator.mk | ||
| PROJECT | ||
| README.md | ||
| VERSION | ||
forgejo-operator
A Kubernetes operator that manages Forgejo (a self-hosted Gitea fork) declaratively. Each piece of Forgejo state — a repository, an organization, a webhook, a push-mirror — is expressed as a Kubernetes custom resource; the operator reconciles the live Forgejo instance (over its Gitea-compatible API) toward that desired state and keeps it there. It is one of four seanfarm operators that share a single reconcile harness, and the one the harness was originally extracted from.
Custom resources
All kinds live in API group forgejo.forgejo.io, version v1alpha1, and are
namespaced. Each CR references a Forgejo instance (forgejoRef, defaulting to
forgejo-http.forgejo:3000 in-cluster) and an admin-token Secret used to
authenticate to Forgejo. (Most kinds name that Secret authSecretRef;
ForgejoPushMirror names it forgejoAuthSecretRef and reserves authSecretRef
for the remote push credential.)
| Kind | Purpose |
|---|---|
ForgejoRepository |
Create or import (migrate) a repository under an owner; reconcile its settings. |
ForgejoOrganization |
Create and reconcile an organization. |
ForgejoUser |
Create and reconcile a user account. |
ForgejoTeam |
Create and reconcile a team within an organization. |
ForgejoWebhook |
Manage a repository webhook (URL, events, secret). |
ForgejoPushMirror |
Configure a push-mirror that replicates a repo to a remote on an interval. |
ForgejoSSHKey |
Register an SSH public key on a user. |
ForgejoOAuthApp |
Manage an OAuth2 application registration. |
ForgejoLabel |
Manage an issue/PR label on a repository. |
ForgejoBranchProtection |
Manage branch-protection rules on a repository. |
ForgejoRelease |
Manage a release on a repository. |
Most kinds publish a Ready print column (kubectl get forgejorepository
shows it). ForgejoPushMirror publishes no print columns — its readiness
is not in kubectl get output; read .status.conditions (the Ready
condition) to see whether the mirror is configured.
Architecture
Every controller is a thin shim over the shared reconcile harness in
libseanfarm-operator
(imported as .../reconcile). The harness owns the entire reconcile lifecycle —
finalizer add/remove, resolving the external client, mapping a handler's result
to the Ready condition, deletion-policy semantics, conflict-safe status
writes, condition/Event emission, and uniform requeue cadence. This repo is
where that loop was first written; the other three seanfarm operators were
built mirroring it, the common pattern was generalized into the library, and
forgejo-operator was then ported back onto the shared implementation.
A controller's Reconcile does only two things: fetch the typed object
(returning early on NotFound) and hand it to runReconcile, which calls the
library's generic reconcile.Run. Per-resource behavior is supplied as a small
Handler with two methods:
Converge(ctx, obj, *gitea.Client) (Outcome, error)— drives the Forgejo side toward the spec and reports whether it is fully reconciled. It returnsConvergedonly once the remote state is verified, andProgressingotherwise (e.g. a repository import whose server-side clone hasn't written its refs yet).Delete(ctx, obj, *gitea.Client) error— removes the remote object.
Ready is structural. The harness — never a handler — sets Ready, and only
when Converge returns Converged. A handler cannot mark a resource Ready
while it still reports the remote as unverified. This is "Ready means verified"
enforced by construction: the ForgejoRepository handler, for instance, holds
Ready=False (Progressing) for the whole duration of an async import.
Other wiring:
- Taxonomy. The library classifies each failure reason as Terminal,
FixedRequeue, or Backoff to decide requeue behavior. This operator's policy
table is deliberately minimal: it sets a single default of Backoff, so
every error falls through to controller-runtime's exponential workqueue
backoff. (Auth/client failures carry distinct reasons —
AuthTokenError,ClientError— for diagnostics, but all back off the same way.) - Deletion policy:
BestEffortRelease. On delete the harness attempts the remote delete, then removes the finalizer regardless of the outcome — so a flaky or unreachable Forgejo can never wedge a CR's deletion. Each handler'sDeleteis itself best-effort (e.g. a repository with no recorded ID is a no-op). - External client. The harness resolves a
*gitea.Clientper reconcile from the CR's auth-token Secret andforgejoRefendpoint before calling the handler. - Secret-watch. Controllers watch Secrets via the library's
MapSecretToRequests: when a referenced auth Secret appears or changes, every CR in that namespace referencing it is re-enqueued immediately, instead of waiting out the full requeue interval. This matters on a fresh cluster where a CR can reconcile before the admin-token Secret has been populated.
Security
Condition reasons/messages and Events land on world-readable status surfaces
(etcd objects, kubectl describe), so they must never carry credential
material.
- Bounded messages. Every condition and Event message the harness writes is
truncated to a fixed 512-byte cap (on a UTF-8 rune boundary). This caps how
much a splatted remote-error body can disclose and prevents an unbounded
message from blowing etcd's per-object size limit. Because the harness owns
condition writes, this operator's conditions inherit the bound for free. The
library also provides a
SanitizingRecorderand bounded Event emission for operators that wire an event recorder. - Name the Secret, never quote it. Truncation bounds disclosure but does not
sanitize; the real protection for credential-bearing paths is a generic
message at the source. The auth-resolution path names the referenced Secret
and key (
secretRef.Name,secretRef.Key) in any error — it never interpolates the token value, DSN, or any other credential into status, Events, or logs.
Build & distribution
- Images are built in-cluster. The operator container image is built by Shipwright (buildkit strategy) inside the seanfarm cluster and cosign-signed — never built or pushed from a laptop.
- Forgejo is canonical; codeberg is the backup. Day-to-day pushes go to the
in-cluster Forgejo at
code.sean.farm. AForgejoPushMirrorbacks the repo up tocodeberg.org/someara/forgejo-operatorroughly every 10 minutes, and a clustermake rebuildre-seeds Forgejo from that codeberg copy. Go module consumers fetch the module from codeberg through the default GOPROXY.
Development
Standard Kubebuilder layout. Work red/green: write or extend an envtest-backed controller test first, watch it fail, then make it pass.
The make targets that exist here:
| Target | Purpose |
|---|---|
make test |
Generate manifests/deepcopy, fmt, vet, then run unit + envtest suites. |
make test-e2e |
Run the e2e suite against a Kind cluster. |
make lint / make lint-fix |
Run golangci-lint (optionally autofixing). |
make manifests / make generate |
Regenerate CRDs/RBAC and DeepCopy code from the +kubebuilder markers. |
make build / make run |
Build the manager binary / run it against your current kubeconfig. |
make install / make deploy |
Install CRDs / deploy the manager to the current cluster. |
CRD or API changes flow through make manifests generate, then the regenerated
CRDs and the new image are rolled out via the in-cluster Shipwright Build/BuildRun
and the Flux/Crossplane pins that reference the operator image — not by building
locally.