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..a72f382e6e --- /dev/null +++ b/pkg/comp-functions/functions/vshnforgejo/runner.go @@ -0,0 +1,269 @@ +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" + 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" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" +) + +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. +// 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 { + 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)) + } + + reg, err := getObservedRunnerRegistration(svc, comp) + if err != nil { + if err == runtime.ErrNotFound { + return runtime.NewWarningResult("waiting for runner registration request to reconcile") + } + return runtime.NewWarningResult(fmt.Sprintf("cannot read runner registration: %s", err)) + } + if reg.Token == "" { + return runtime.NewWarningResult("runner registration response not yet available") + } + + 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)) + } + + 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)) + + // 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{ + "deletionPolicy": "Orphan", + "providerConfigRef": map[string]any{ + "name": "http", + }, + "forProvider": map[string]any{ + "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"}, + }, + }, + }, + }, + } + req.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "http.crossplane.io", + Version: "v1alpha2", + Kind: "DisposableRequest", + }) + req.SetName(comp.GetName() + runnerTokenRequestSuffix) + + return svc.SetDesiredKubeObject(req, comp.GetName()+runnerTokenRequestSuffix) +} + +// 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 reg, err + } + + responseBody, found, err := unstructured.NestedString(observed.Object, "status", "response", "body") + if err != nil || !found || responseBody == "" { + return reg, nil + } + + if err := json.Unmarshal([]byte(responseBody), ®); err != nil { + return reg, fmt.Errorf("cannot parse runner registration response: %w", err) + } + + 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 + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: runtime.EscapeDNS1123(comp.GetName()+runnerConfigSecretSuffix, false), + Namespace: comp.GetInstanceNamespace(), + }, + StringData: map[string]string{ + ".runner": string(runnerFile), + }, + } + + return svc.SetDesiredKubeObject(secret, comp.GetName()+runnerConfigSecretSuffix) +} + +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{ + "existingSecret": runtime.EscapeDNS1123(comp.GetName()+runnerConfigSecretSuffix, false), + }, + }, + } + + 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) +}