weebo-forgejo

Migrate an existing Forgejo

Bring users, organizations, teams, repositories, webhooks and deploy keys that already exist under the operator with the importer.

If your Forgejo already has users, organizations and repositories, the operator won't touch them on its own. A CR whose name is already taken in Forgejo stays Ready: False with reason ForgejoObjectAlreadyExists, so the operator never takes over an object by accident.

To bring existing objects under management, use weebo-forgejo-importer. It reads the live instance and creates the matching CRs with each Forgejo id already recorded in their status. The operator then manages that exact object instead of trying to create a new one.

Before you begin

  • The operator is installed and a ForgejoInstance is connected.
  • A site-administrator token for the importer (read access is enough).
  • For --apply, a kube context allowed to create the CRs and patch their status: cluster-admin, or the least-privilege role below.
  • If a ForgejoNamespacePolicy exists, the import namespace must be allowed for the imported organizations.

What gets imported

ForgejoCRNotes
UsersForgejoUserAs managementPolicy: Observe by default (see below)
OrganizationsForgejoOrganization
Teams and their membersForgejoTeamExcept Owners, which Forgejo manages
RepositoriesForgejoRepositorySettings, units, branch protections and team access. Pull mirrors keep their migrate.mirrorInterval.
Repository and organization webhooksForgejoWebhookWithout secretRef (see below)
Deploy keysForgejoDeployKeyWith their publicKey

Not imported: OAuth2 applications and access tokens. Forgejo never returns their secrets, so the operator could never hand them out. Recreate them as new CRs (Credentials).

Every imported CR gets deletionPolicy: Orphan: these objects existed before the operator, and deleting a manifest must not destroy them. Switch a CR to Delete once you want that.

Run the importer

The importer ships in the operator image. Run it from a workstation with your kubeconfig mounted. A small shell function keeps the commands below short (use podman instead of docker if that's what you have):

export FORGEJO_TOKEN=<site-admin-token>

importer() {
  docker run --rm -e FORGEJO_TOKEN \
    -v "$HOME/.kube/config:/kubeconfig:ro" -e KUBECONFIG=/kubeconfig \
    --entrypoint /usr/local/bin/weebo-forgejo-importer \
    ghcr.io/batleforc/weebo-forgejo-operator:<version> "$@"
}
FlagDefaultDescription
--url / FORGEJO_URLInstance URL, as in ForgejoInstance.spec.url
--token / FORGEJO_TOKENSite-administrator token
--namespace, -ndefaultNamespace for repositories, webhooks and deploy keys
--instance-refnoneWritten as spec.instanceRef.name on every CR
--owner (repeatable)everythingOnly these organizations/users and their repositories. Other users are imported only when a team or repository references them.
--manage-usersoffImport users as Manage instead of Observe
--applyoffCreate the CRs and seed their status. Without it, the YAML is printed.

Warnings, such as a team member that isn't a known user or a team left out of a repository's teamRefs, are printed to stderr.

Adopt safely: review, apply, then commit

status is a subresource: kubectl apply and GitOps tools never write it. That's why the adoption happens in the cluster, once, before the manifests go to Git.

Review

Print what would be imported, without touching the cluster:

importer --url https://git.example.com --namespace forgejo --owner weebo > forgejo.yaml

Adopt

Run the same command with --apply:

importer --url https://git.example.com --namespace forgejo --owner weebo --apply

Each CR is created paused, with the forgejo.weebo.io/import-pending annotation. Its status.forgejoId and forgejoName are written, and only then is the annotation removed. The operator never sees an imported CR without its id, so it can't create a duplicate.

Commit

Commit forgejo.yaml to your GitOps repository. Its status blocks are ignored when applied; the CRs already exist with their ids.

Check

kubectl get fjorg,fjteam,fjuser
kubectl -n forgejo get fjrepo,fjhook,fjdkey

Everything should read READY True. The first reconcile of an adopted CR sends nothing to Forgejo when the imported spec matches.

Importer RBAC

Without --apply the importer never talks to the cluster. With it, it reads the existing CRs (to keep names stable), creates the new ones by server-side apply, seeds their status and removes the pause annotation. It never reads or writes Secrets. A dedicated identity needs no more than:

# Every imported kind is namespaced: bound only in the import namespace
# (--namespace).
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: weebo-forgejo-importer-namespaced
rules:
  - apiGroups: ["forgejo.weebo.io"]
    resources: [forgejousers, forgejoorganizations, forgejoteams,
                forgejorepositories, forgejowebhooks, forgejodeploykeys]
    verbs: ["get", "list", "create", "patch"]
  - apiGroups: ["forgejo.weebo.io"]
    resources: [forgejousers/status, forgejoorganizations/status, forgejoteams/status,
                forgejorepositories/status, forgejowebhooks/status, forgejodeploykeys/status]
    verbs: ["patch"]
---
# The unique-claim check reads the CRs of every namespace and the
# instances' URLs.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: weebo-forgejo-importer-claims
rules:
  - apiGroups: ["forgejo.weebo.io"]
    resources: [forgejousers, forgejoorganizations, forgejoteams,
                forgejoinstances, forgejolocalinstances]
    verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: weebo-forgejo-importer-claims
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: weebo-forgejo-importer-claims
subjects:
  - kind: ServiceAccount        # or the User/Group running the importer
    name: weebo-forgejo-importer
    namespace: forgejo
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: weebo-forgejo-importer
  namespace: forgejo            # the --namespace you import into
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: weebo-forgejo-importer-namespaced
subjects:
  - kind: ServiceAccount
    name: weebo-forgejo-importer
    namespace: forgejo

Patching status is what lets it adopt an object: keep this role off anyone who shouldn't be able to point a CR at an arbitrary Forgejo object, and remove the binding once the migration is done. The CRs still go through the admission webhook, so the allow-list applies to the import namespace. A user, organization or team another CR already owns on the same server isn't adopted twice (RemoteObjectClaimed).

Finish by hand

  • Users are imported as Observe: they're never modified, only looked up, so teams and repositories can reference them.
    • With --manage-users they become Manage. A local account is then marked bot: true, because its password can't be known, and the operator never resets it, whatever later changes to the CR: only a password the operator handed out itself (status.passwordEmitted) is ever replaced.
    • A site admin imported with --manage-users keeps admin: true, which needs an Allow rule naming it in allowedUsers for the ForgejoUser kind and the import namespace, even without any policy (see Security model). Without one, its CR is refused.
    • Turning such a user into a regular local user (bot: false plus secretTargets) resets its password into the targets.
  • Webhook secrets are write-only in Forgejo. An imported hook keeps its current secret, and edits leave it alone. Adding a secretRef recreates the hook with that secret, which loses its delivery history.
  • Organization owners (ownerRef) only matter at creation, so they're left unset.

From cluster-scoped users, organizations and teams

Releases before the namespaced identities had ForgejoUser, ForgejoOrganization, ForgejoTeam and ForgejoUserKey cluster-scoped. A CRD's scope can't change in place: its CRD — and with it every CR of the kind — must be deleted. weebo-forgejo-operator migrate-to-namespaced moves them without touching Forgejo:

Plan. Choose a namespace per CR — a mapping file of Kind/name: namespace lines, and a default for the others — and check the plan; it writes the new manifests (for your GitOps repository) and plan.json to --out:

mapping.yaml
ForgejoUser/alice: team-a
ForgejoOrganization/weebo: platform
weebo-forgejo-operator migrate-to-namespaced --dry-run \
  --mapping mapping.yaml --default-namespace platform --out ./migration

A reference whose target lands in another namespace gets that namespace, and the target lists the referencing namespace in its sharedWith. Such references need references.crossNamespace: true on the release, and --allow-cross-namespace to acknowledge it.

Stop the operator (replicas: 0): no finalizer must run while the old CRs are deleted. Nothing is deleted in Forgejo.

Migrate: the same command with --yes instead of --dry-run. It removes the finalizers of the old CRs, deletes them and their CRDs, then waits for the new CRDs: apply them now (crds.yaml of the release, or the chart with crds.install). It recreates each CR in its namespace with its binding to Forgejo (forgejoId, the resolved instance…), rewrites the references of the namespaced CRs, and renames the owner recorded on the Secrets the users emitted (ForgejoUser/alice → ForgejoUser/team-a/alice). A run interrupted after the deletion resumes from plan.json.

Start the operator, and commit the manifests of --out in place of the old ones (Argo CD would otherwise re-apply cluster-scoped manifests).

Vault values keep their old owner id (the tool can't reach Vault): each moved ForgejoUser/ForgejoUserKey carries it in its forgejo.weebo.io/previous-owner annotation, and the operator takes those values back, recording its new id on its next write. Remove the annotation once every value is rewritten.

One thing the tool can't do for you: a user that wrote into another namespace than the one it moves to is refused (CrossNamespaceReference) until its secretTargets follow it (the dry run warns).

Next

Hand out credentials for the imported repositories: Credentials.

On this page