weebo-forgejo

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 --raw

Or 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                      # PEM

Check it

kubectl get fji           # fji = ForgejoInstance
kubectl describe fji main
NAME   URL                       READY   AGE
main   https://git.example.com   True    12s

The 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.

ReasonFix
TokenSecretNotFoundCheck the Secret's name, namespace and key
InstanceUnreachableCheck the URL, DNS, network policies and TLS (tls.caSecretRef for a private CA)
UnsupportedForgejoVersionUpgrade Forgejo to v16 or later
ForgejoApiErrorForgejo 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=true
apiVersion: forgejo.weebo.io/v1alpha1
kind: ForgejoLocalInstance
metadata:
  name: main
  namespace: team-a
spec:
  url: https://git.example.com
  tokenSecretRef:
    name: forgejo-token
    key: token

instanceRef 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: LocalInstance

Upgrading 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-forgejo

Writing 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 segments

Every 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 covers argocd-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, ForgejoUserKey or ForgejoDeployKey in 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 apply by the admission webhook, and by the reconciler (SecretTargetFailed). An invalid grant puts the instance Ready: False (InvalidVaultPathGrants). Removing a grant leaves the values in place: the CRs writing there fail their reconcile, and their deletion, until it is restored.

Next

Users, OIDC and teams.

On this page