Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkg/comp-functions/functions/vshnforgejo/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ func init() {
Name: "billing",
Execute: AddBilling,
},
{
Name: "runner",
Execute: DeployForgejoRunner,
},
{
Name: "additional-resources",
Execute: common.AddAdditionalResources[*vshnv1.VSHNForgejo],
Expand Down
269 changes: 269 additions & 0 deletions pkg/comp-functions/functions/vshnforgejo/runner.go
Original file line number Diff line number Diff line change
@@ -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), &reg); 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)
}
Loading