From 63d1092006e0bb77ec6a2d93143b3a6e96a311cd Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Tue, 26 May 2026 10:03:17 +0200 Subject: [PATCH 1/2] PoC: Forgejo runners This is a PoC of deploying a "managed" forgejo runner --- .../functions/vshnforgejo/register.go | 4 + .../functions/vshnforgejo/runner.go | 210 ++++++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 pkg/comp-functions/functions/vshnforgejo/runner.go diff --git a/pkg/comp-functions/functions/vshnforgejo/register.go b/pkg/comp-functions/functions/vshnforgejo/register.go index 54be9d985a..04ed712b00 100644 --- a/pkg/comp-functions/functions/vshnforgejo/register.go +++ b/pkg/comp-functions/functions/vshnforgejo/register.go @@ -30,6 +30,10 @@ func init() { Name: "billing", Execute: AddBilling, }, + { + Name: "runner", + Execute: DeployForgejoRunner, + }, { Name: "additional-resources", Execute: common.AddAdditionalResources[*vshnv1.VSHNForgejo], diff --git a/pkg/comp-functions/functions/vshnforgejo/runner.go b/pkg/comp-functions/functions/vshnforgejo/runner.go new file mode 100644 index 0000000000..53c224a49c --- /dev/null +++ b/pkg/comp-functions/functions/vshnforgejo/runner.go @@ -0,0 +1,210 @@ +package vshnforgejo + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + + xfnproto "github.com/crossplane/function-sdk-go/proto/v1" + xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" + xhelmv1 "github.com/vshn/appcat/v4/apis/helm/release/v1beta1" + vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" + "github.com/vshn/appcat/v4/pkg/comp-functions/runtime" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" +) + +const ( + runnerTokenRequestSuffix = "-runner-token-req" + runnerReleaseSuffix = "-runner" + runnerChartRepository = "oci://codeberg.org/wrenix/helm-charts" + runnerChartName = "forgejo-runner" + runnerChartVersion = "0.7.6" +) + +// DeployForgejoRunner deploys a Forgejo Actions runner. +// Uses provider-http to fetch a registration token then deploys the runner Helm chart. +func DeployForgejoRunner(ctx context.Context, comp *vshnv1.VSHNForgejo, svc *runtime.ServiceRuntime) *xfnproto.Result { + err := svc.GetObservedComposite(comp) + if err != nil { + return runtime.NewFatalResult(fmt.Errorf("cannot get composite: %w", err)) + } + + // Wait for the Forgejo release to be ready before bootstrapping the runner. + // Two reasons: the admin API must be reachable so the token request doesn't + // fail with connection-refused, and the admin password must have stabilised. + // provider-http marks forProvider.headers immutable, so if the password is + // still churning during bootstrap the token request gets wedged on an + // immutable-field patch and only recovers via manual deletion. Once the token + // request exists we keep reconciling it even if Forgejo briefly flaps, so we + // don't drop it from the desired state. + if ready, _ := svc.IsResourceReady(comp.GetName()); !ready && + !svc.ResourceExistsInObserved(comp.GetName()+runnerTokenRequestSuffix) { + return runtime.NewWarningResult("waiting for forgejo to become ready before deploying runner") + } + + credSecretName := runtime.EscapeDNS1123(comp.GetName()+"-credentials-secret", false) + connDetails, err := svc.GetObservedComposedResourceConnectionDetails(credSecretName) + if err != nil { + if err == runtime.ErrNotFound { + return runtime.NewWarningResult("waiting for admin credentials secret") + } + return runtime.NewWarningResult(fmt.Sprintf("cannot get admin credentials: %s", err)) + } + password := string(connDetails["password"]) + if password == "" { + return runtime.NewWarningResult("admin password not yet available") + } + + forgejoURL := fmt.Sprintf("http://%s-http.%s.svc:3000", helmFullname(comp), comp.GetInstanceNamespace()) + + if err := deployHTTPProviderConfig(svc, comp); err != nil { + return runtime.NewWarningResult(fmt.Sprintf("cannot deploy http provider config: %s", err)) + } + + if err := deployRunnerTokenRequest(svc, comp, forgejoURL, password); err != nil { + return runtime.NewWarningResult(fmt.Sprintf("cannot deploy runner token request: %s", err)) + } + + token, err := getObservedRunnerToken(svc, comp) + if err != nil { + if err == runtime.ErrNotFound { + return runtime.NewWarningResult("waiting for runner token request to reconcile") + } + return runtime.NewWarningResult(fmt.Sprintf("cannot read runner token: %s", err)) + } + if token == "" { + return runtime.NewWarningResult("runner token response not yet available") + } + + if err := deployRunnerRelease(svc, comp, forgejoURL, token); err != nil { + return runtime.NewWarningResult(fmt.Sprintf("cannot deploy runner release: %s", err)) + } + + return nil +} + +func deployHTTPProviderConfig(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo) error { + pc := &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "credentials": map[string]any{ + "source": "None", + }, + }, + }, + } + pc.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "http.crossplane.io", + Version: "v1alpha1", + Kind: "ProviderConfig", + }) + pc.SetName("http") + + return svc.SetDesiredKubeObject(pc, comp.GetName()+"-http-providerconfig") +} + +func deployRunnerTokenRequest(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo, forgejoURL, password string) error { + basicAuth := base64.StdEncoding.EncodeToString([]byte("forgejo_admin:" + password)) + + req := &unstructured.Unstructured{ + Object: map[string]any{ + "spec": map[string]any{ + "deletionPolicy": "Orphan", + "providerConfigRef": map[string]any{ + "name": "http", + }, + "forProvider": map[string]any{ + "url": forgejoURL + "/api/v1/admin/runners/registration-token", + "method": "GET", + "headers": map[string]any{ + "Authorization": []any{"basic " + basicAuth}, + }, + }, + }, + }, + } + req.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "http.crossplane.io", + Version: "v1alpha2", + Kind: "DisposableRequest", + }) + req.SetName(comp.GetName() + runnerTokenRequestSuffix) + + return svc.SetDesiredKubeObject(req, comp.GetName()+runnerTokenRequestSuffix) +} + +func getObservedRunnerToken(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo) (string, error) { + observed := &unstructured.Unstructured{} + if err := svc.GetObservedKubeObject(observed, comp.GetName()+runnerTokenRequestSuffix); err != nil { + return "", err + } + + responseBody, found, err := unstructured.NestedString(observed.Object, "status", "response", "body") + if err != nil || !found || responseBody == "" { + return "", nil + } + + var tokenResp struct { + Token string `json:"token"` + } + if err := json.Unmarshal([]byte(responseBody), &tokenResp); err != nil { + return "", fmt.Errorf("cannot parse runner token response: %w", err) + } + + return tokenResp.Token, nil +} + +func deployRunnerRelease(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo, forgejoURL, token string) error { + values := map[string]any{ + "knownLastVersion": true, + "runner": map[string]any{ + "config": map[string]any{ + "create": true, + "instance": forgejoURL, + "name": comp.GetName() + "-runner", + "token": token, + }, + }, + } + + vb, err := json.Marshal(values) + if err != nil { + return err + } + + releaseName := comp.GetName() + runnerReleaseSuffix + release := &xhelmv1.Release{ + ObjectMeta: metav1.ObjectMeta{ + Name: releaseName, + }, + Spec: xhelmv1.ReleaseSpec{ + RollbackRetriesLimit: ptr.To[int32](5), + ForProvider: xhelmv1.ReleaseParameters{ + Chart: xhelmv1.ChartSpec{ + Repository: runnerChartRepository, + Name: runnerChartName, + Version: runnerChartVersion, + }, + Namespace: comp.GetInstanceNamespace(), + SkipCreateNamespace: true, + ValuesSpec: xhelmv1.ValuesSpec{ + Values: k8sruntime.RawExtension{ + Raw: vb, + }, + }, + }, + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &xpv1.Reference{ + Name: "helm", + }, + }, + }, + } + + return svc.SetDesiredComposedResource(release) +} From 2557f27a8834cbac4779ddb75f3c321cb0b0b62b Mon Sep 17 00:00:00 2001 From: Mike Ditton Date: Tue, 26 May 2026 16:24:40 +0200 Subject: [PATCH 2/2] Use non deprecated api endpoint to register runner --- .../functions/vshnforgejo/runner.go | 103 ++++++++++++++---- 1 file changed, 81 insertions(+), 22 deletions(-) diff --git a/pkg/comp-functions/functions/vshnforgejo/runner.go b/pkg/comp-functions/functions/vshnforgejo/runner.go index 53c224a49c..a72f382e6e 100644 --- a/pkg/comp-functions/functions/vshnforgejo/runner.go +++ b/pkg/comp-functions/functions/vshnforgejo/runner.go @@ -11,6 +11,7 @@ import ( xhelmv1 "github.com/vshn/appcat/v4/apis/helm/release/v1beta1" vshnv1 "github.com/vshn/appcat/v4/apis/vshn/v1" "github.com/vshn/appcat/v4/pkg/comp-functions/runtime" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" k8sruntime "k8s.io/apimachinery/pkg/runtime" @@ -20,14 +21,18 @@ import ( const ( runnerTokenRequestSuffix = "-runner-token-req" + runnerConfigSecretSuffix = "-runner-config" runnerReleaseSuffix = "-runner" + runnerRegistrationNote = "This file is automatically generated by AppCat. Do not edit it manually." runnerChartRepository = "oci://codeberg.org/wrenix/helm-charts" runnerChartName = "forgejo-runner" runnerChartVersion = "0.7.6" ) // DeployForgejoRunner deploys a Forgejo Actions runner. -// Uses provider-http to fetch a registration token then deploys the runner Helm chart. +// It registers a global runner via provider-http (POST /admin/actions/runners), +// writes the returned credential into a .runner secret, and deploys the runner Helm +// chart pointed at that secret. func DeployForgejoRunner(ctx context.Context, comp *vshnv1.VSHNForgejo, svc *runtime.ServiceRuntime) *xfnproto.Result { err := svc.GetObservedComposite(comp) if err != nil { @@ -70,18 +75,22 @@ func DeployForgejoRunner(ctx context.Context, comp *vshnv1.VSHNForgejo, svc *run return runtime.NewWarningResult(fmt.Sprintf("cannot deploy runner token request: %s", err)) } - token, err := getObservedRunnerToken(svc, comp) + reg, err := getObservedRunnerRegistration(svc, comp) if err != nil { if err == runtime.ErrNotFound { - return runtime.NewWarningResult("waiting for runner token request to reconcile") + return runtime.NewWarningResult("waiting for runner registration request to reconcile") } - return runtime.NewWarningResult(fmt.Sprintf("cannot read runner token: %s", err)) + return runtime.NewWarningResult(fmt.Sprintf("cannot read runner registration: %s", err)) } - if token == "" { - return runtime.NewWarningResult("runner token response not yet available") + if reg.Token == "" { + return runtime.NewWarningResult("runner registration response not yet available") } - if err := deployRunnerRelease(svc, comp, forgejoURL, token); err != nil { + if err := deployRunnerConfigSecret(svc, comp, forgejoURL, reg); err != nil { + return runtime.NewWarningResult(fmt.Sprintf("cannot deploy runner config secret: %s", err)) + } + + if err := deployRunnerRelease(svc, comp); err != nil { return runtime.NewWarningResult(fmt.Sprintf("cannot deploy runner release: %s", err)) } @@ -111,6 +120,16 @@ func deployHTTPProviderConfig(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForg func deployRunnerTokenRequest(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo, forgejoURL, password string) error { basicAuth := base64.StdEncoding.EncodeToString([]byte("forgejo_admin:" + password)) + // POST /admin/actions/runners registers a global runner and returns its token. + // The GET registration-token endpoint is deprecated since Forgejo 15. + body, err := json.Marshal(map[string]any{ + "name": comp.GetName() + runnerReleaseSuffix, + "ephemeral": false, + }) + if err != nil { + return err + } + req := &unstructured.Unstructured{ Object: map[string]any{ "spec": map[string]any{ @@ -119,10 +138,12 @@ func deployRunnerTokenRequest(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForg "name": "http", }, "forProvider": map[string]any{ - "url": forgejoURL + "/api/v1/admin/runners/registration-token", - "method": "GET", + "url": forgejoURL + "/api/v1/admin/actions/runners", + "method": "POST", + "body": string(body), "headers": map[string]any{ "Authorization": []any{"basic " + basicAuth}, + "Content-Type": []any{"application/json"}, }, }, }, @@ -138,36 +159,74 @@ func deployRunnerTokenRequest(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForg return svc.SetDesiredKubeObject(req, comp.GetName()+runnerTokenRequestSuffix) } -func getObservedRunnerToken(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo) (string, error) { +// runnerRegistration mirrors the RegisterRunnerResponse returned by +// POST /admin/actions/runners and the fields of the forgejo-runner .runner file. +type runnerRegistration struct { + ID int64 `json:"id"` + UUID string `json:"uuid"` + Token string `json:"token"` +} + +func getObservedRunnerRegistration(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo) (runnerRegistration, error) { + reg := runnerRegistration{} observed := &unstructured.Unstructured{} if err := svc.GetObservedKubeObject(observed, comp.GetName()+runnerTokenRequestSuffix); err != nil { - return "", err + return reg, err } responseBody, found, err := unstructured.NestedString(observed.Object, "status", "response", "body") if err != nil || !found || responseBody == "" { - return "", nil + return reg, nil + } + + if err := json.Unmarshal([]byte(responseBody), ®); err != nil { + return reg, fmt.Errorf("cannot parse runner registration response: %w", err) } - var tokenResp struct { - Token string `json:"token"` + return reg, nil +} + +// deployRunnerConfigSecret writes the forgejo-runner .runner credential file built +// from the registration response. POST /admin/actions/runners already registers the +// runner server-side, so we provide the resulting credential directly instead of +// letting the chart's register job (which expects a registration token) run. +func deployRunnerConfigSecret(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo, forgejoURL string, reg runnerRegistration) error { + runnerFile, err := json.Marshal(map[string]any{ + "WARNING": runnerRegistrationNote, + "id": reg.ID, + "uuid": reg.UUID, + "name": comp.GetName() + runnerReleaseSuffix, + "token": reg.Token, + "address": forgejoURL, + "labels": []string{}, + }) + if err != nil { + return err } - if err := json.Unmarshal([]byte(responseBody), &tokenResp); err != nil { - return "", fmt.Errorf("cannot parse runner token response: %w", err) + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: runtime.EscapeDNS1123(comp.GetName()+runnerConfigSecretSuffix, false), + Namespace: comp.GetInstanceNamespace(), + }, + StringData: map[string]string{ + ".runner": string(runnerFile), + }, } - return tokenResp.Token, nil + return svc.SetDesiredKubeObject(secret, comp.GetName()+runnerConfigSecretSuffix) } -func deployRunnerRelease(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo, forgejoURL, token string) error { +func deployRunnerRelease(svc *runtime.ServiceRuntime, comp *vshnv1.VSHNForgejo) error { + // existingSecret points the chart at the .runner credential we built from the + // registration response. This also disables the chart's config-generate hook + // (rendered only when create && !existingSecret), which would otherwise try to + // re-register with a registration token we don't have. values := map[string]any{ "knownLastVersion": true, "runner": map[string]any{ "config": map[string]any{ - "create": true, - "instance": forgejoURL, - "name": comp.GetName() + "-runner", - "token": token, + "existingSecret": runtime.EscapeDNS1123(comp.GetName()+runnerConfigSecretSuffix, false), }, }, }