Files
actions-runner-controller/controllers/actions.github.com/ephemeralrunnerset_controller.go
T

1082 lines
39 KiB
Go

/*
Copyright 2020 The actions-runner-controller authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package actionsgithubcom
import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"sort"
"strconv"
"sync"
"time"
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
"github.com/actions/actions-runner-controller/controllers/actions.github.com/metrics"
"github.com/actions/actions-runner-controller/controllers/actions.github.com/multiclient"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/actions/scaleset"
"github.com/go-logr/logr"
corev1 "k8s.io/api/core/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/builder"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
"sigs.k8s.io/controller-runtime/pkg/event"
"sigs.k8s.io/controller-runtime/pkg/predicate"
)
const (
// EphemeralRunnerSetFinalizerName is the finalizer name used in EphemeralRunnerSet resource to protect the cleanup process of the child ephemeral runners and proxy secret.
EphemeralRunnerSetFinalizerName = "ephemeralrunnerset.actions.github.com/finalizer"
)
// EphemeralRunnerSetReconciler reconciles a EphemeralRunnerSet object
type EphemeralRunnerSetReconciler struct {
client.Client
Log logr.Logger
Scheme *runtime.Scheme
PublishMetrics bool
*ResourceBuilder
specHashCache sync.Map
scaleStateCache sync.Map
}
type specHashCacheEntry struct {
uid types.UID
generation int64
hash string
}
type scaleStateCacheEntry struct {
uid types.UID
patchID int
replicas int
}
// +kubebuilder:rbac:groups=actions.github.com,resources=ephemeralrunnersets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=actions.github.com,resources=ephemeralrunnersets/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=actions.github.com,resources=ephemeralrunnersets/finalizers,verbs=update;patch
// +kubebuilder:rbac:groups=actions.github.com,resources=ephemeralrunners,verbs=get;list;watch;create;update;patch;delete;deletecollection
// +kubebuilder:rbac:groups=actions.github.com,resources=ephemeralrunners/status,verbs=get
// +kubebuilder:rbac:groups=core,resources=secrets,verbs=create;delete;get;list;patch;watch
// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
//
// The responsibility of this controller is to bring the state to the desired one, but it should
// avoid patching itself, because of the frequent patches that the listener is doing.
// The safe point where we can patch the resource is when we are reacting on finalizer.
// Then, the listener should be deleted first, to allow controller clean up resources without interruptions
//
// The resource should be created with finalizer. To leave it to this controller to add it, we would
// risk the same issue of patching the status. Responsibility of this controller should only
// be to bring the count of EphemeralRunners to the desired one, not to patch this resource
// until it is safe to do so
func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
log := r.Log.WithValues("ephemeralrunnerset", req.NamespacedName)
var ephemeralRunnerSet v1alpha1.EphemeralRunnerSet
if err := r.Get(ctx, req.NamespacedName, &ephemeralRunnerSet); err != nil {
if kerrors.IsNotFound(err) {
r.clearSpecHashCache(req.NamespacedName)
r.clearScaleStateCache(req.NamespacedName)
}
return ctrl.Result{}, client.IgnoreNotFound(err)
}
// Requested deletion does not need reconciled.
if !ephemeralRunnerSet.DeletionTimestamp.IsZero() {
if !controllerutil.ContainsFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) {
return ctrl.Result{}, nil
}
log.Info("Deleting resources")
done, err := r.cleanUpEphemeralRunners(ctx, &ephemeralRunnerSet, log)
if err != nil {
log.Error(err, "Failed to clean up EphemeralRunners")
return ctrl.Result{}, err
}
if !done {
log.Info("Waiting for resources to be deleted")
return ctrl.Result{}, nil
}
done, err = r.cleanUpEphemeralRunnerSetProxySecret(ctx, &ephemeralRunnerSet, log)
if err != nil {
log.Error(err, "Failed to clean up EphemeralRunnerSet proxy secret")
return ctrl.Result{}, err
}
if !done {
log.Info("Waiting for proxy secret to be deleted")
return ctrl.Result{RequeueAfter: 1 * time.Second}, nil
}
log.Info("Removing finalizer")
if controllerutil.ContainsFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) {
original := ephemeralRunnerSet.DeepCopy()
controllerutil.RemoveFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName)
if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil {
log.Error(err, "Failed to update ephemeral runner set with removed finalizer")
return ctrl.Result{}, err
}
}
log.Info("Successfully removed finalizer after cleanup")
r.clearSpecHashCache(req.NamespacedName)
r.clearScaleStateCache(req.NamespacedName)
return ctrl.Result{}, nil
}
// Add finalizer if not present
if !controllerutil.ContainsFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName) {
log.Info("Adding finalizer")
original := ephemeralRunnerSet.DeepCopy()
controllerutil.AddFinalizer(&ephemeralRunnerSet, EphemeralRunnerSetFinalizerName)
if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil {
log.Error(err, "Failed to update ephemeral runner set with new finalizer")
return ctrl.Result{}, err
}
log.Info("Successfully added finalizer")
return ctrl.Result{}, nil
}
patchEphemeralRunnerSetProxySecretData := func(proxySecret *corev1.Secret) (bool, error) {
proxySecretData, err := ephemeralRunnerSet.Spec.EphemeralRunnerSpec.Proxy.ToSecretData(func(s string) (*corev1.Secret, error) {
secret := new(corev1.Secret)
err := r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunnerSet.Namespace, Name: s}, secret)
return secret, err
})
if err != nil {
return false, fmt.Errorf("failed to convert proxy config to secret data: %w", err)
}
if maps.EqualFunc(proxySecret.Data, proxySecretData, bytes.Equal) {
return false, nil
}
desiredProxySecret, err := r.newEphemeralRunnerSetProxySecret(&ephemeralRunnerSet, proxySecretData)
if err != nil {
return false, fmt.Errorf("failed to build ephemeralRunnerSet proxy secret: %w", err)
}
updatedProxySecret := proxySecret.DeepCopy()
updatedProxySecret.Data = proxySecretData
updatedProxySecret.Labels = r.filterAndMergeLabels(proxySecret.Labels, desiredProxySecret.Labels)
updatedProxySecret.Annotations = r.filterAndMergeAnnotations(proxySecret.Annotations, desiredProxySecret.Annotations)
log.Info("Updating ephemeralRunnerSet proxy secret")
if err := r.Patch(ctx, updatedProxySecret, client.MergeFrom(proxySecret)); err != nil {
return false, fmt.Errorf("failed to update ephemeralRunnerSet proxy secret: %w", err)
}
return true, nil
}
// If hash spec has changed, delete idle ephemeral runners
// in order to apply the change to the runners that did not yet receive a job.
storedIntegrityHash := ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash]
if !r.hasSpecHashCache(req.NamespacedName, ephemeralRunnerSet.UID, ephemeralRunnerSet.Generation, storedIntegrityHash) {
ephemeralRunnerIntegrityHash := ephemeralRunnerSetIntegrityHash(&ephemeralRunnerSet)
if storedIntegrityHash != ephemeralRunnerIntegrityHash {
log.Info("EphemeralRunnerSpec has changed, deleting idle ephemeral runners to apply the new spec")
if _, err := r.cleanUpEphemeralRunners(ctx, &ephemeralRunnerSet, log); err != nil {
log.Error(err, "Failed to clean up EphemeralRunners")
return ctrl.Result{}, err
}
if ephemeralRunnerSet.Spec.EphemeralRunnerSpec.Proxy != nil {
var proxySecret corev1.Secret
err := r.Get(
ctx,
types.NamespacedName{
Namespace: ephemeralRunnerSet.Namespace,
Name: proxyEphemeralRunnerSetSecretName(&ephemeralRunnerSet),
},
&proxySecret,
)
switch {
case err == nil:
updated, err := patchEphemeralRunnerSetProxySecretData(&proxySecret)
if err != nil {
return ctrl.Result{}, err
}
if updated {
return ctrl.Result{RequeueAfter: 1}, nil
}
case kerrors.IsNotFound(err):
log.Info("Creating a ephemeralRunnerSet proxy secret for the runner pods")
if err := r.createProxySecret(ctx, &ephemeralRunnerSet, log); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to create ephemeralRunnerSet proxy secret: %w", err)
}
default:
log.Error(err, "Unable to get ephemeralRunnerSet proxy secret", "namespace", ephemeralRunnerSet.Namespace, "name", proxyEphemeralRunnerSetSecretName(&ephemeralRunnerSet))
return ctrl.Result{}, err
}
}
log.Info("Updating EphemeralRunnerSet with new spec hash")
original := ephemeralRunnerSet.DeepCopy()
if ephemeralRunnerSet.Annotations == nil {
ephemeralRunnerSet.Annotations = make(map[string]string)
}
ephemeralRunnerSet.Annotations[AnnotationKeyIntegrityHash] = ephemeralRunnerIntegrityHash
if err := r.Patch(ctx, &ephemeralRunnerSet, client.MergeFrom(original)); err != nil {
log.Error(err, "Failed to update ephemeral runner set with new spec hash")
return ctrl.Result{}, err
}
r.setSpecHashCache(req.NamespacedName, ephemeralRunnerSet.UID, ephemeralRunnerSet.Generation, ephemeralRunnerIntegrityHash)
log.Info("Updated ephemeral runner set with new spec hash")
return ctrl.Result{}, nil
}
r.setSpecHashCache(req.NamespacedName, ephemeralRunnerSet.UID, ephemeralRunnerSet.Generation, ephemeralRunnerIntegrityHash)
}
if ephemeralRunnerSet.Status.Phase == v1alpha1.EphemeralRunnerSetPhaseOutdated {
if _, err := r.cleanUpEphemeralRunners(ctx, &ephemeralRunnerSet, log); err != nil {
log.Error(err, "Failed to clean up EphemeralRunners")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
var proxySecret *corev1.Secret
if ephemeralRunnerSet.Spec.EphemeralRunnerSpec.Proxy != nil {
var secret corev1.Secret
err := r.Get(
ctx,
types.NamespacedName{
Namespace: ephemeralRunnerSet.Namespace,
Name: proxyEphemeralRunnerSetSecretName(&ephemeralRunnerSet),
},
&secret,
)
proxySecret = &secret
switch {
case err == nil:
updated, err := patchEphemeralRunnerSetProxySecretData(proxySecret)
if err != nil {
return ctrl.Result{}, err
}
if updated {
return ctrl.Result{RequeueAfter: 1}, nil
}
case kerrors.IsNotFound(err):
// Create a compiled secret for the runner pods in the runnerset namespace
log.Info("Creating a ephemeralRunnerSet proxy secret for the runner pods")
if err := r.createProxySecret(ctx, &ephemeralRunnerSet, log); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to create ephemeralRunnerSet proxy secret: %w", err)
}
return ctrl.Result{}, nil
default:
log.Error(err, "Unable to get ephemeralRunnerSet proxy secret", "namespace", ephemeralRunnerSet.Namespace, "name", proxyEphemeralRunnerSetSecretName(&ephemeralRunnerSet))
return ctrl.Result{}, err
}
}
// Find all EphemeralRunner with matching namespace and own by this EphemeralRunnerSet.
var ephemeralRunnerList v1alpha1.EphemeralRunnerList
if err := r.List(
ctx,
&ephemeralRunnerList,
client.InNamespace(req.Namespace),
client.MatchingFields{resourceOwnerKey: req.Name},
); err != nil {
log.Error(err, "Unable to list child ephemeral runners")
return ctrl.Result{}, err
}
runnerState := newEphemeralRunnersByStates(&ephemeralRunnerList, false, ephemeralRunnerSet.Spec.PatchID != 0)
log.Info(
"Ephemeral runner counts",
"outdated", runnerState.outdatedCount,
"pending", runnerState.pendingCount,
"running", runnerState.runningCount,
"finished", runnerState.finishedCount,
"failed", runnerState.failedCount,
"deleting", runnerState.deletingCount,
)
if r.PublishMetrics {
githubConfigURL := ephemeralRunnerSet.Spec.EphemeralRunnerSpec.GitHubConfigURL
parsedURL, err := actions.ParseGitHubConfigFromURL(githubConfigURL)
if err != nil {
log.Error(err, "Github Config URL is invalid", "URL", githubConfigURL)
// stop reconciling on this object
return ctrl.Result{}, nil
}
metrics.SetEphemeralRunnerCountsByLifecycle(
metrics.CommonLabels{
Name: ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetName],
Namespace: ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetNamespace],
Repository: parsedURL.Repository,
Organization: parsedURL.Organization,
Enterprise: parsedURL.Enterprise,
},
runnerState.pendingCount,
runnerState.runningCount,
runnerState.finishedCount,
runnerState.failedCount,
runnerState.outdatedCount,
runnerState.deletingCount,
)
}
total := runnerState.scaleTotal()
scaleState := r.getScaleState(req.NamespacedName, &ephemeralRunnerSet, total)
scalingTotal := total
if ephemeralRunnerSet.Spec.PatchID > 0 {
scalingTotal = scaleState.replicas
}
if ephemeralRunnerSet.Spec.PatchID == 0 || ephemeralRunnerSet.Spec.PatchID != runnerState.latestPatchID {
var fullState *ephemeralRunnersByState
getFullState := func() *ephemeralRunnersByState {
if fullState == nil {
fullState = newEphemeralRunnersByStates(&ephemeralRunnerList, true, ephemeralRunnerSet.Spec.PatchID != 0)
}
return fullState
}
log.Info("Scaling comparison", "current", scalingTotal, "clusterCurrent", total, "desired", ephemeralRunnerSet.Spec.Replicas)
switch {
case scalingTotal < ephemeralRunnerSet.Spec.Replicas: // Handle scale up
count := ephemeralRunnerSet.Spec.Replicas - scalingTotal
log.Info("Creating new ephemeral runners (scale up)", "count", count)
if err := r.createEphemeralRunners(ctx, &ephemeralRunnerSet, count, log); err != nil {
log.Error(err, "failed to make ephemeral runner")
return ctrl.Result{}, err
}
if ephemeralRunnerSet.Spec.PatchID > 0 {
scaleState.replicas += count
r.setScaleStateCache(req.NamespacedName, scaleState)
}
case ephemeralRunnerSet.Spec.PatchID > 0 && total >= ephemeralRunnerSet.Spec.Replicas: // Handle scale down scenario.
// If ephemeral runner did not yet update the phase to succeeded, but the scale down
// request is issued, we should ignore the scale down request.
// Eventually, the ephemeral runner will be cleaned up on the next patch request, which happens
// on the next batch
case ephemeralRunnerSet.Spec.PatchID == 0 && total > ephemeralRunnerSet.Spec.Replicas:
stateWithSlices := getFullState()
count := total - ephemeralRunnerSet.Spec.Replicas
log.Info("Deleting ephemeral runners (scale down)", "count", count)
if err := r.deleteIdleEphemeralRunners(
ctx,
&ephemeralRunnerSet,
stateWithSlices.pending,
stateWithSlices.running,
count,
log,
); err != nil {
log.Error(err, "failed to delete idle runners")
return ctrl.Result{}, err
}
}
}
if err := r.updateStatus(ctx, &ephemeralRunnerSet, runnerState, scaleState, log); err != nil {
return ctrl.Result{}, err
}
if proxySecret != nil {
expectedScaleSetName := ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetName]
expectedScaleSetNamespace := ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetNamespace]
labelsChanged := proxySecret.Labels[LabelKeyGitHubScaleSetName] != expectedScaleSetName || proxySecret.Labels[LabelKeyGitHubScaleSetNamespace] != expectedScaleSetNamespace
if labelsChanged {
updatedProxySecret := proxySecret.DeepCopy()
updatedProxySecret.Labels = r.filterAndMergeLabels(proxySecret.Labels, map[string]string{
LabelKeyGitHubScaleSetName: expectedScaleSetName,
LabelKeyGitHubScaleSetNamespace: expectedScaleSetNamespace,
})
log.Info("Updating ephemeralRunnerSet proxy secret metadata")
if err := r.Patch(ctx, updatedProxySecret, client.MergeFrom(proxySecret)); err != nil {
return ctrl.Result{}, fmt.Errorf("failed to update ephemeralRunnerSet proxy secret metadata: %w", err)
}
return ctrl.Result{RequeueAfter: 1}, nil
}
}
return ctrl.Result{}, nil
}
func (r *EphemeralRunnerSetReconciler) setSpecHashCache(namespacedName types.NamespacedName, uid types.UID, generation int64, hash string) {
r.specHashCache.Store(namespacedName, specHashCacheEntry{uid: uid, generation: generation, hash: hash})
}
func (r *EphemeralRunnerSetReconciler) hasSpecHashCache(namespacedName types.NamespacedName, uid types.UID, generation int64, hash string) bool {
entry, ok := r.specHashCache.Load(namespacedName)
if !ok {
return false
}
specHashEntry, ok := entry.(specHashCacheEntry)
if !ok {
return false
}
return specHashEntry.uid == uid && specHashEntry.generation == generation && specHashEntry.hash == hash
}
func (r *EphemeralRunnerSetReconciler) clearSpecHashCache(namespacedName types.NamespacedName) {
r.specHashCache.Delete(namespacedName)
}
func (r *EphemeralRunnerSetReconciler) setScaleStateCache(namespacedName types.NamespacedName, entry scaleStateCacheEntry) {
r.scaleStateCache.Store(namespacedName, entry)
}
func (r *EphemeralRunnerSetReconciler) getScaleState(namespacedName types.NamespacedName, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, clusterReplicas int) scaleStateCacheEntry {
if ephemeralRunnerSet.Spec.PatchID == 0 {
return scaleStateCacheEntry{uid: ephemeralRunnerSet.UID, patchID: 0, replicas: clusterReplicas}
}
entry, ok := r.scaleStateCache.Load(namespacedName)
if ok {
stateEntry, ok := entry.(scaleStateCacheEntry)
if ok && stateEntry.uid == ephemeralRunnerSet.UID && stateEntry.patchID == ephemeralRunnerSet.Spec.PatchID {
if stateEntry.replicas < clusterReplicas {
stateEntry.replicas = clusterReplicas
r.setScaleStateCache(namespacedName, stateEntry)
}
return stateEntry
}
}
replicas := clusterReplicas
if ephemeralRunnerSet.Status.ReservedPatchID == ephemeralRunnerSet.Spec.PatchID && ephemeralRunnerSet.Status.ReservedReplicas > replicas {
replicas = ephemeralRunnerSet.Status.ReservedReplicas
}
if replicas > ephemeralRunnerSet.Spec.Replicas {
replicas = ephemeralRunnerSet.Spec.Replicas
}
stateEntry := scaleStateCacheEntry{uid: ephemeralRunnerSet.UID, patchID: ephemeralRunnerSet.Spec.PatchID, replicas: replicas}
r.setScaleStateCache(namespacedName, stateEntry)
return stateEntry
}
func (r *EphemeralRunnerSetReconciler) clearScaleStateCache(namespacedName types.NamespacedName) {
r.scaleStateCache.Delete(namespacedName)
}
func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, state *ephemeralRunnersByState, scaleState scaleStateCacheEntry, log logr.Logger) error {
total := state.scaleTotal()
var phase v1alpha1.EphemeralRunnerSetPhase
switch {
case state.outdatedCount > 0:
phase = v1alpha1.EphemeralRunnerSetPhaseOutdated
case ephemeralRunnerSet.Status.Phase == "":
phase = v1alpha1.EphemeralRunnerSetPhaseRunning
default:
phase = ephemeralRunnerSet.Status.Phase
}
desiredStatus := v1alpha1.EphemeralRunnerSetStatus{
CurrentReplicas: total,
Phase: phase,
PendingEphemeralRunners: state.pendingCount,
RunningEphemeralRunners: state.runningCount,
FailedEphemeralRunners: state.failedCount,
}
if ephemeralRunnerSet.Spec.PatchID > 0 {
desiredStatus.ReservedReplicas = scaleState.replicas
desiredStatus.ReservedPatchID = scaleState.patchID
}
// Update the status if needed.
if ephemeralRunnerSet.Status != desiredStatus {
updated := ephemeralRunnerSet.DeepCopy()
updated.Status = desiredStatus
if err := r.Status().Patch(ctx, updated, client.MergeFrom(ephemeralRunnerSet)); err != nil {
log.Error(err, "Failed to update EphemeralRunnerSet status")
return err
}
log.Info("Updated EphemeralRunnerSet status", "status", updated.Status)
}
return nil
}
func (r *EphemeralRunnerSetReconciler) cleanUpProxySecret(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, log logr.Logger) error {
if ephemeralRunnerSet.Spec.EphemeralRunnerSpec.Proxy == nil {
return nil
}
log.Info("Deleting proxy secret")
proxySecret := new(corev1.Secret)
proxySecret.Namespace = ephemeralRunnerSet.Namespace
proxySecret.Name = proxyEphemeralRunnerSetSecretName(ephemeralRunnerSet)
if err := r.Delete(ctx, proxySecret); err != nil && !kerrors.IsNotFound(err) {
return fmt.Errorf("failed to delete proxy secret: %w", err)
}
log.Info("Deleted proxy secret")
return nil
}
func (r *EphemeralRunnerSetReconciler) cleanUpEphemeralRunners(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, log logr.Logger) (bool, error) {
ephemeralRunnerList := new(v1alpha1.EphemeralRunnerList)
err := r.List(ctx, ephemeralRunnerList, client.InNamespace(ephemeralRunnerSet.Namespace), client.MatchingFields{resourceOwnerKey: ephemeralRunnerSet.Name})
if err != nil {
return false, fmt.Errorf("failed to list child ephemeral runners: %w", err)
}
// only if there are no ephemeral runners left, return true
if len(ephemeralRunnerList.Items) == 0 {
err := r.cleanUpProxySecret(ctx, ephemeralRunnerSet, log)
if err != nil {
return false, err
}
log.Info("All ephemeral runners are deleted")
return true, nil
}
ephemeralRunnerState := newEphemeralRunnersByStates(ephemeralRunnerList, true, false)
log.Info(
"Clean up runner counts",
"pending", ephemeralRunnerState.pendingCount,
"running", ephemeralRunnerState.runningCount,
"finished", ephemeralRunnerState.finishedCount,
"failed", ephemeralRunnerState.failedCount,
"deleting", ephemeralRunnerState.deletingCount,
"outdated", ephemeralRunnerState.outdatedCount,
)
// avoid fetching the client if we have nothing left to do
if len(ephemeralRunnerState.running) == 0 && len(ephemeralRunnerState.pending) == 0 {
if ephemeralRunnerState.finishedCount+ephemeralRunnerState.failedCount+ephemeralRunnerState.outdatedCount > 0 {
log.Info("Cleanup terminated ephemeral runners")
if err := r.deleteAllEphemeralRunnersForSet(ctx, ephemeralRunnerSet, log); err != nil {
log.Error(err, "Failed to delete terminated ephemeral runners")
return false, err
}
}
return false, nil
}
log.Info("Cleanup terminated ephemeral runners")
if err := r.deleteEphemeralRunners(ctx, ephemeralRunnerState.finished, log); err != nil {
log.Error(err, "Failed to delete finished ephemeral runners")
return false, err
}
if err := r.deleteEphemeralRunners(ctx, ephemeralRunnerState.failed, log); err != nil {
log.Error(err, "Failed to delete failed ephemeral runners")
return false, err
}
if err := r.deleteEphemeralRunners(ctx, ephemeralRunnerState.outdated, log); err != nil {
log.Error(err, "Failed to delete outdated ephemeral runners")
return false, err
}
log.Info("Cleanup pending or running ephemeral runners")
candidates := append([]*v1alpha1.EphemeralRunner{}, ephemeralRunnerState.pending...)
for _, ephemeralRunner := range ephemeralRunnerState.running {
if ephemeralRunner.HasJob() {
log.Info(
"Skipping ephemeral runner since it is running a job",
"name", ephemeralRunner.Name,
"workflowRunId", ephemeralRunner.Status.WorkflowRunID,
"jobId", ephemeralRunner.Status.JobID,
)
continue
}
candidates = append(candidates, ephemeralRunner)
}
if len(candidates) == 0 {
return false, nil
}
actionsClient, err := r.GetActionsService(ctx, ephemeralRunnerSet)
if err != nil {
return false, err
}
if _, err := r.deleteEphemeralRunnersWithActionsClient(ctx, candidates, actionsClient, log); err != nil {
log.Error(err, "Failed to remove ephemeral runners from the service")
return false, err
}
return false, nil
}
func (r *EphemeralRunnerSetReconciler) cleanUpEphemeralRunnerSetProxySecret(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, log logr.Logger) (done bool, err error) {
if ephemeralRunnerSet.Spec.EphemeralRunnerSpec.Proxy == nil {
return true, nil
}
var proxySecret corev1.Secret
err = r.Get(
ctx,
types.NamespacedName{
Namespace: ephemeralRunnerSet.Namespace,
Name: proxyEphemeralRunnerSetSecretName(ephemeralRunnerSet),
},
&proxySecret,
)
switch {
case err == nil:
log.Info("Deleting proxy secret")
if err := r.Delete(ctx, &proxySecret); err != nil && !kerrors.IsNotFound(err) {
log.Error(err, "Failed to delete proxy secret")
return false, err
}
log.Info("Deleted proxy secret")
return false, nil
case kerrors.IsNotFound(err):
log.Info("Proxy secret already deleted")
return true, nil
default:
log.Error(
err,
"Unable to get ephemeralRunnerSet proxy secret",
"namespace",
ephemeralRunnerSet.Namespace,
"name",
proxyEphemeralRunnerSetSecretName(ephemeralRunnerSet),
)
return false, err
}
}
// createEphemeralRunners provisions `count` number of v1alpha1.EphemeralRunner resources in the cluster.
func (r *EphemeralRunnerSetReconciler) createEphemeralRunners(ctx context.Context, runnerSet *v1alpha1.EphemeralRunnerSet, count int, log logr.Logger) error {
r.setSchemeIfUnset(r.Scheme)
for i := 0; i < count; i++ {
ephemeralRunner, err := r.newEphemeralRunner(runnerSet)
if err != nil {
log.Error(err, "failed to build ephemeral runner")
return err
}
if runnerSet.Spec.EphemeralRunnerSpec.Proxy != nil {
ephemeralRunner.Spec.ProxySecretRef = proxyEphemeralRunnerSetSecretName(runnerSet)
}
if err := r.Create(ctx, ephemeralRunner); err != nil {
log.Error(err, "failed to make ephemeral runner")
return err
}
log.Info("Created new ephemeral runner", "progress", i+1, "total", count, "runner", ephemeralRunner.Name)
}
return nil
}
func (r *EphemeralRunnerSetReconciler) createProxySecret(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, log logr.Logger) error {
proxySecretData, err := ephemeralRunnerSet.Spec.EphemeralRunnerSpec.Proxy.ToSecretData(func(s string) (*corev1.Secret, error) {
secret := new(corev1.Secret)
err := r.Get(ctx, types.NamespacedName{Namespace: ephemeralRunnerSet.Namespace, Name: s}, secret)
return secret, err
})
if err != nil {
return fmt.Errorf("failed to convert proxy config to secret data: %w", err)
}
runnerPodProxySecret, err := r.newEphemeralRunnerSetProxySecret(ephemeralRunnerSet, proxySecretData)
if err != nil {
log.Error(err, "failed to build proxy secret")
return err
}
log.Info("Creating new proxy secret")
if err := r.Create(ctx, runnerPodProxySecret); err != nil {
log.Error(err, "failed to create proxy secret")
return err
}
log.Info("Created new proxy secret")
return nil
}
// deleteIdleEphemeralRunners try to deletes `count` number of v1alpha1.EphemeralRunner resources in the cluster.
// It will only delete `v1alpha1.EphemeralRunner` that has registered with Actions service
// which has a `v1alpha1.EphemeralRunner.Status.RunnerId` set.
// So, it is possible that this function will not delete enough ephemeral runners
// if there are not enough ephemeral runners that have registered with Actions service.
// When this happens, the next reconcile loop will try to delete the remaining ephemeral runners
// after we get notified by any of the `v1alpha1.EphemeralRunner.Status` updates.
func (r *EphemeralRunnerSetReconciler) deleteIdleEphemeralRunners(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, pendingEphemeralRunners, runningEphemeralRunners []*v1alpha1.EphemeralRunner, count int, log logr.Logger) error {
if count <= 0 {
return nil
}
runners := newEphemeralRunnerStepper(pendingEphemeralRunners, runningEphemeralRunners)
if runners.len() == 0 {
log.Info("No pending or running ephemeral runners running at this time for scale down")
return nil
}
candidates := make([]*v1alpha1.EphemeralRunner, 0, runners.len())
for runners.next() {
ephemeralRunner := runners.object()
isDone := ephemeralRunner.IsDone()
if !isDone && ephemeralRunner.Status.RunnerID == 0 {
log.Info("Skipping ephemeral runner since it is not registered yet", "name", ephemeralRunner.Name)
continue
}
if !isDone && ephemeralRunner.HasJob() {
log.Info(
"Skipping ephemeral runner since it is running a job",
"name", ephemeralRunner.Name,
"workflowRunId", ephemeralRunner.Status.WorkflowRunID,
"jobId", ephemeralRunner.Status.JobID,
)
continue
}
candidates = append(candidates, ephemeralRunner)
}
if len(candidates) == 0 {
return nil
}
actionsClient, err := r.GetActionsService(ctx, ephemeralRunnerSet)
if err != nil {
return fmt.Errorf("failed to create actions client for ephemeral runner replica set: %w", err)
}
remaining := count
for _, ephemeralRunner := range candidates {
if remaining <= 0 {
return nil
}
log.Info("Removing the ephemeral runner from the service", "name", ephemeralRunner.Name)
deleted, err := r.deleteEphemeralRunnerWithActionsClient(ctx, ephemeralRunner, actionsClient, log)
if err != nil {
return err
}
if deleted {
remaining--
}
}
return nil
}
func (r *EphemeralRunnerSetReconciler) deleteAllEphemeralRunnersForSet(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, log logr.Logger) error {
log.Info("Deleting all ephemeral runners", "ephemeralRunnerSetUID", ephemeralRunnerSet.UID)
if err := r.DeleteAllOf(
ctx,
&v1alpha1.EphemeralRunner{},
client.InNamespace(ephemeralRunnerSet.Namespace),
client.MatchingLabels{LabelKeyEphemeralRunnerSetUID: string(ephemeralRunnerSet.UID)},
); err != nil {
return err
}
log.Info("Deleted all ephemeral runners", "ephemeralRunnerSetUID", ephemeralRunnerSet.UID)
return nil
}
func (r *EphemeralRunnerSetReconciler) deleteEphemeralRunners(ctx context.Context, ephemeralRunners []*v1alpha1.EphemeralRunner, log logr.Logger) error {
for _, ephemeralRunner := range ephemeralRunners {
log.Info("Deleting ephemeral runner", "name", ephemeralRunner.Name)
if err := r.Delete(ctx, ephemeralRunner); err != nil && !kerrors.IsNotFound(err) {
return err
}
}
return nil
}
func (r *EphemeralRunnerSetReconciler) deleteEphemeralRunnersWithActionsClient(ctx context.Context, ephemeralRunners []*v1alpha1.EphemeralRunner, actionsClient multiclient.Client, log logr.Logger) (int, error) {
deletedCount := 0
for _, ephemeralRunner := range ephemeralRunners {
log.Info("Removing the ephemeral runner from the service", "name", ephemeralRunner.Name)
ok, err := r.deleteEphemeralRunnerWithActionsClient(ctx, ephemeralRunner, actionsClient, log)
if err != nil {
return deletedCount, err
}
if ok {
deletedCount++
}
}
return deletedCount, nil
}
func (r *EphemeralRunnerSetReconciler) deleteEphemeralRunnerWithActionsClient(ctx context.Context, ephemeralRunner *v1alpha1.EphemeralRunner, actionsClient multiclient.Client, log logr.Logger) (bool, error) {
if err := actionsClient.RemoveRunner(ctx, int64(ephemeralRunner.Status.RunnerID)); err != nil {
if errors.Is(err, scaleset.JobStillRunningError) {
log.Info("Runner is still running a job, skipping deletion", "name", ephemeralRunner.Name, "runnerId", ephemeralRunner.Status.RunnerID)
return false, nil
}
return false, err
}
log.Info("Deleting ephemeral runner after removing from the service", "name", ephemeralRunner.Name, "runnerId", ephemeralRunner.Status.RunnerID)
if err := r.Delete(ctx, ephemeralRunner); err != nil && !kerrors.IsNotFound(err) {
return false, err
}
log.Info("Deleted ephemeral runner", "name", ephemeralRunner.Name, "runnerId", ephemeralRunner.Status.RunnerID)
return true, nil
}
// SetupWithManager sets up the controller with the Manager.
func (r *EphemeralRunnerSetReconciler) SetupWithManager(mgr ctrl.Manager, opts ...Option) error {
r.setSchemeIfUnset(r.Scheme)
return builderWithOptions(
ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.EphemeralRunnerSet{}).
Owns(&v1alpha1.EphemeralRunner{}, builder.WithPredicates(ephemeralRunnerSetChildPredicate())).
WithEventFilter(predicate.ResourceVersionChangedPredicate{}),
opts,
).Complete(r)
}
func ephemeralRunnerSetChildPredicate() predicate.Predicate {
return predicate.Funcs{
CreateFunc: func(event.CreateEvent) bool {
return true
},
DeleteFunc: func(event.DeleteEvent) bool {
return true
},
UpdateFunc: func(e event.UpdateEvent) bool {
return ephemeralRunnerUpdateAffectsScale(e.ObjectOld, e.ObjectNew)
},
GenericFunc: func(event.GenericEvent) bool {
return false
},
}
}
func ephemeralRunnerUpdateAffectsScale(oldObj, newObj client.Object) bool {
newRunner, ok := newObj.(*v1alpha1.EphemeralRunner)
if !ok || newRunner == nil {
return false
}
oldRunner, ok := oldObj.(*v1alpha1.EphemeralRunner)
if !ok || oldRunner == nil {
return true
}
if oldRunner.Annotations[AnnotationKeyPatchID] != newRunner.Annotations[AnnotationKeyPatchID] {
return true
}
if oldRunner.Status.Phase != newRunner.Status.Phase {
return true
}
if oldRunner.Status.RunnerID != newRunner.Status.RunnerID {
return true
}
if ephemeralRunnerIsDeleting(oldRunner) != ephemeralRunnerIsDeleting(newRunner) {
return true
}
return ephemeralRunnerCountsTowardScale(oldRunner) != ephemeralRunnerCountsTowardScale(newRunner)
}
func ephemeralRunnerIsDeleting(ephemeralRunner *v1alpha1.EphemeralRunner) bool {
deletionTimestamp := ephemeralRunner.GetDeletionTimestamp()
return deletionTimestamp != nil && !deletionTimestamp.IsZero()
}
func ephemeralRunnerCountsTowardScale(ephemeralRunner *v1alpha1.EphemeralRunner) bool {
if ephemeralRunnerIsDeleting(ephemeralRunner) {
return false
}
switch ephemeralRunner.Status.Phase {
case v1alpha1.EphemeralRunnerPhaseSucceeded, v1alpha1.EphemeralRunnerPhaseOutdated:
return false
default:
return true
}
}
type ephemeralRunnerStepper struct {
buckets [][]*v1alpha1.EphemeralRunner
bucketIndex int
itemIndex int
}
func newEphemeralRunnerStepper(primary []*v1alpha1.EphemeralRunner, othersOrdered ...[]*v1alpha1.EphemeralRunner) *ephemeralRunnerStepper {
sort.Slice(primary, func(i, j int) bool {
return primary[i].GetCreationTimestamp().Time.Before(primary[j].GetCreationTimestamp().Time)
})
buckets := make([][]*v1alpha1.EphemeralRunner, 0, len(othersOrdered)+1)
buckets = append(buckets, primary)
for _, bucket := range othersOrdered {
sort.Slice(bucket, func(i, j int) bool {
return bucket[i].GetCreationTimestamp().Time.Before(bucket[j].GetCreationTimestamp().Time)
})
buckets = append(buckets, bucket)
}
return &ephemeralRunnerStepper{
buckets: buckets,
bucketIndex: 0,
itemIndex: -1,
}
}
func (s *ephemeralRunnerStepper) next() bool {
for s.bucketIndex < len(s.buckets) {
if s.itemIndex+1 < len(s.buckets[s.bucketIndex]) {
s.itemIndex++
return true
}
s.bucketIndex++
s.itemIndex = -1
}
return false
}
func (s *ephemeralRunnerStepper) object() *v1alpha1.EphemeralRunner {
if s.bucketIndex >= 0 && s.bucketIndex < len(s.buckets) && s.itemIndex >= 0 && s.itemIndex < len(s.buckets[s.bucketIndex]) {
return s.buckets[s.bucketIndex][s.itemIndex]
}
return nil
}
func (s *ephemeralRunnerStepper) len() int {
var total int
for i := range s.buckets {
total += len(s.buckets[i])
}
return total
}
type ephemeralRunnersByState struct {
pending []*v1alpha1.EphemeralRunner
running []*v1alpha1.EphemeralRunner
finished []*v1alpha1.EphemeralRunner
failed []*v1alpha1.EphemeralRunner
deleting []*v1alpha1.EphemeralRunner
outdated []*v1alpha1.EphemeralRunner
pendingCount int
runningCount int
finishedCount int
failedCount int
deletingCount int
outdatedCount int
latestPatchID int
}
func newEphemeralRunnersByStates(ephemeralRunnerList *v1alpha1.EphemeralRunnerList, includeSlices bool, trackLatestPatchID bool) *ephemeralRunnersByState {
var ephemeralRunnerState ephemeralRunnersByState
latestPatchIDValue := ""
for i := range ephemeralRunnerList.Items {
r := &ephemeralRunnerList.Items[i]
if trackLatestPatchID {
latestPatchIDValue = trackLatestPatchIDValue(r.Annotations[AnnotationKeyPatchID], latestPatchIDValue, &ephemeralRunnerState.latestPatchID)
}
if !r.DeletionTimestamp.IsZero() {
ephemeralRunnerState.deletingCount++
continue
}
switch r.Status.Phase {
case v1alpha1.EphemeralRunnerPhaseRunning:
ephemeralRunnerState.runningCount++
case v1alpha1.EphemeralRunnerPhaseSucceeded:
ephemeralRunnerState.finishedCount++
case v1alpha1.EphemeralRunnerPhaseFailed:
ephemeralRunnerState.failedCount++
case v1alpha1.EphemeralRunnerPhaseOutdated:
ephemeralRunnerState.outdatedCount++
default:
// Pending or no phase should be considered as pending.
//
// If field is not set, that means that the EphemeralRunner
// did not yet have chance to update the Status.Phase field.
ephemeralRunnerState.pendingCount++
}
}
if !includeSlices {
return &ephemeralRunnerState
}
ephemeralRunnerState.pending = make([]*v1alpha1.EphemeralRunner, 0, ephemeralRunnerState.pendingCount)
ephemeralRunnerState.running = make([]*v1alpha1.EphemeralRunner, 0, ephemeralRunnerState.runningCount)
ephemeralRunnerState.finished = make([]*v1alpha1.EphemeralRunner, 0, ephemeralRunnerState.finishedCount)
ephemeralRunnerState.failed = make([]*v1alpha1.EphemeralRunner, 0, ephemeralRunnerState.failedCount)
ephemeralRunnerState.deleting = make([]*v1alpha1.EphemeralRunner, 0, ephemeralRunnerState.deletingCount)
ephemeralRunnerState.outdated = make([]*v1alpha1.EphemeralRunner, 0, ephemeralRunnerState.outdatedCount)
for i := range ephemeralRunnerList.Items {
r := &ephemeralRunnerList.Items[i]
if !r.DeletionTimestamp.IsZero() {
ephemeralRunnerState.deleting = append(ephemeralRunnerState.deleting, r)
continue
}
switch r.Status.Phase {
case v1alpha1.EphemeralRunnerPhaseRunning:
ephemeralRunnerState.running = append(ephemeralRunnerState.running, r)
case v1alpha1.EphemeralRunnerPhaseSucceeded:
ephemeralRunnerState.finished = append(ephemeralRunnerState.finished, r)
case v1alpha1.EphemeralRunnerPhaseFailed:
ephemeralRunnerState.failed = append(ephemeralRunnerState.failed, r)
case v1alpha1.EphemeralRunnerPhaseOutdated:
ephemeralRunnerState.outdated = append(ephemeralRunnerState.outdated, r)
default:
ephemeralRunnerState.pending = append(ephemeralRunnerState.pending, r)
}
}
return &ephemeralRunnerState
}
func trackLatestPatchIDValue(value, latestValue string, latestPatchID *int) string {
if value == "" || len(value) < len(latestValue) || (len(value) == len(latestValue) && value <= latestValue) {
return latestValue
}
patchID, err := strconv.Atoi(value)
if err != nil || patchID <= *latestPatchID {
return latestValue
}
*latestPatchID = patchID
return value
}
func (s *ephemeralRunnersByState) scaleTotal() int {
return s.pendingCount + s.runningCount + s.failedCount
}