Connect a Forgejo
Point the operator at a Forgejo with a site-admin token, and choose where secrets are written.
A ForgejoInstance tells the operator which Forgejo to manage and how to
authenticate. Every other CR uses it.
Before you begin
- The operator is installed.
- You have a site administrator account on Forgejo v16+.
Connect
Create an admin token
The operator acts as a site administrator. For OAuth2 applications, it also
acts on behalf of their owner through Forgejo's Sudo header. Give the token
every scope; all is simplest.
Where you can run the forgejo CLI (on the Forgejo host, or with
kubectl exec into the Forgejo pod):
forgejo admin user generate-access-token --username <admin> \
--token-name weebo-forgejo --scopes all --rawOr in the web UI, signed in as the admin: Settings → Applications → Generate new token, with every permission set to Read and write.
Store it in a Secret
From your workstation:
kubectl -n weebo-forgejo create secret generic forgejo-admin \
--from-literal=token=<the-token>The token is read again on every reconcile, so you can rotate it by updating this Secret, with no restart.
Create the ForgejoInstance
apiVersion: forgejo.weebo.io/v1alpha1
kind: ForgejoInstance
metadata:
name: main
spec:
url: https://git.example.com # the web root, not /api/v1
tokenSecretRef:
name: forgejo-admin
namespace: weebo-forgejo
key: token
tls: # only for a private CA
caSecretRef:
name: internal-ca
namespace: weebo-forgejo
key: ca.crt # PEMCheck it
kubectl get fji # fji = ForgejoInstance
kubectl describe fji mainNAME URL READY AGE
main https://git.example.com True 12sThe Ready condition's message shows the Forgejo version it found, such as
Forgejo 16.0.5. When it is False, the reason says why. A token that
lacks permissions can still connect; the other CRs then report
ForgejoApiError.
| Reason | Fix |
|---|---|
TokenSecretNotFound | Check the Secret's name, namespace and key |
InstanceUnreachable | Check the URL, DNS, network policies and TLS (tls.caSecretRef for a private CA) |
UnsupportedForgejoVersion | Upgrade Forgejo to v16 or later |
ForgejoApiError | Forgejo refused a request: check that the token belongs to a site admin and has every scope |
One instance or several
Every other CR has an optional instanceRef: { name: main }. Without it,
the cluster's only ForgejoInstance is used. With zero instances, those
CRs report InstanceNotFound. With several, they report
AmbiguousDefaultInstance, so set instanceRef on each of them. A CR
already bound without instanceRef keeps its instance: creating a second
one doesn't move it (if its instance is deleted and another becomes the
default, it reports InstanceBindingChanged).
Once resolved, the instance is recorded in status.instance and a CR
never moves: re-pointing instanceRef elsewhere leaves the CR
Ready: False (InstanceBindingChanged) without touching anything. Decide
it when you create the CR; to move a CR to another instance, delete it
(deletionPolicy: Orphan keeps the Forgejo object) and create it again. See
Security model.
A namespace's own instance
A ForgejoLocalInstance is a ForgejoInstance owned by a namespace: its
token (and TLS CA) live in a Secret of that namespace, and only the CRs of
that namespace use it, unless its sharedWith lists other namespaces
(namespaces, namespaceSelector) or accepts cluster-scoped objects. CRs
bound to it are not governed by the allow-list —
the namespace's own token bounds what they can do — and write their secrets
as Kubernetes Secrets only (Vault targets need a ForgejoInstance: the
operator's Vault identity is shared by every namespace).
The token Secret must carry the label
forgejo.weebo.io/instance-token: "true". The operator reads it with its
own rights, not with those of whoever created the instance: without that
consent, anyone allowed to create a ForgejoLocalInstance could have the
operator send any Secret of the namespace to a URL of their choosing. An
unlabelled Secret leaves the instance Ready: False
(TokenSecretNotAllowed); labelling it is picked up at the next retry.
kubectl -n team-a create secret generic forgejo-token --from-literal=token=<token>
kubectl -n team-a label secret forgejo-token forgejo.weebo.io/instance-token=trueapiVersion: forgejo.weebo.io/v1alpha1
kind: ForgejoLocalInstance
metadata:
name: main
namespace: team-a
spec:
url: https://git.example.com
tokenSecretRef:
name: forgejo-token
key: tokeninstanceRef finds the instance by name: an operator watching the whole
cluster tries the ForgejoInstance first, then the ForgejoLocalInstance
of the CR's namespace; one limited to a namespace (watch.namespace) only
uses its local instances. kind (Instance or LocalInstance) and
namespace (another namespace's local instance) pin one:
spec:
instanceRef:
name: main
kind: LocalInstanceUpgrading from a release where instanceRef was a bare name: stop the
operator, apply the new CRDs, run weebo-forgejo-operator migrate-instance-ref (--dry-run first), start the operator — and rewrite
instanceRef: main as instanceRef: {name: main} in your manifests.
Where secrets go
Several kinds generate credentials: local users' passwords, SSH keys, OAuth2
client secrets, access tokens. Each CR can choose where its credential goes
with secretTargets. When a CR
doesn't, the instance's secretStore.backend decides. The default is
kubernetes: a Secret named after the CR, in the CR's namespace.
When to use Vault
Use Vault when credentials must reach workloads outside this cluster, or when your policy keeps secrets out of etcd. The Vault connection is declared once on the instance and reused by every Vault target:
spec:
secretStore:
backend: vault # default for CRs without secretTargets
vault:
address: https://vault.example.com:8200
mount: secret # a KV version 2 engine
pathPrefix: weebo-forgejo # default path: <pathPrefix>/<namespace>/<name> ("": <namespace>/<name>)
kubernetesAuthRole: weebo-forgejo
caSecretRef: { name: vault-ca, namespace: weebo-forgejo, key: ca.crt }The operator logs in with its own ServiceAccount through Vault's Kubernetes auth method, with no static Vault token. Bind the role to the operator's ServiceAccount and give it write access under the prefix:
vault policy write weebo-forgejo - <<'EOF'
path "secret/data/weebo-forgejo/*" { capabilities = ["create", "read", "update", "delete"] }
path "secret/metadata/weebo-forgejo/*" { capabilities = ["read", "delete"] }
EOF
vault write auth/kubernetes/role/weebo-forgejo \
bound_service_account_names=weebo-forgejo \
bound_service_account_namespaces=weebo-forgejo \
token_policies=weebo-forgejoWriting outside the namespace subtree
A CR's Vault targets stay under <pathPrefix>/<namespace>/, explicit
path included: the operator's Vault identity is shared by every
namespace, and without that bound anyone allowed to create a CR would
write wherever the operator can. When an admin namespace publishes
credentials where their consumers already read them, open those paths to it
with pathGrants:
spec:
secretStore:
vault:
# …
pathGrants:
- namespaces: [platform] # exact names, *, or a prefix like team-*
paths:
- argocd/forgejo # exact path
- +/forgejo # + = exactly one segment
- namespaces: [team-*]
paths: [shared/*] # a last * = one or more segmentsEvery CR that writes credentials is namespaced: a grant names
namespaces, and clusterScoped: true is refused by the CRD schema.
- Paths are compared segment by segment (
argocd/*never coversargocd-dev/…), and every pattern holds at least one literal segment:*,+/*or+/+are refused by the CRD schema. - A grant opens its paths to everyone allowed to create a
ForgejoOAuth2Application,ForgejoAccessToken,ForgejoUser,ForgejoUserKeyorForgejoDeployKeyin a covered namespace:team-*opens them to every team. Combine it with RBAC and the allow-list. - Ownership still applies: a value another CR owns is never overwritten. But a value no CR owns yet (written by hand, by Terraform) is taken over by the first covered CR writing it: prefer exact paths where such values live.
- The Vault policy of the operator's role stays the hard bound: grant it the same paths, not the whole mount.
- A path out of reach is refused at
kubectl applyby the admission webhook, and by the reconciler (SecretTargetFailed). An invalid grant puts the instanceReady: False(InvalidVaultPathGrants). Removing a grant leaves the values in place: the CRs writing there fail their reconcile, and their deletion, until it is restored.