mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-09-30 11:20:24 +02:00
Use metrics to display runner statuses instead of status field for EphemeralRunnerSet and AutoscalingRunnerSet
This commit is contained in:
@@ -36,12 +36,7 @@ import (
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:printcolumn:JSONPath=".spec.minRunners",name=Minimum Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".spec.maxRunners",name=Maximum Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.currentRunners",name=Current Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.phase",name=Phase,type=string
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.pendingEphemeralRunners",name=Pending Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.runningEphemeralRunners",name=Running Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.finishedEphemeralRunners",name=Finished Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.deletingEphemeralRunners",name=Deleting Runners,type=integer
|
||||
|
||||
// AutoscalingRunnerSet is the Schema for the autoscalingrunnersets API
|
||||
type AutoscalingRunnerSet struct {
|
||||
@@ -314,20 +309,8 @@ type HistogramMetric struct {
|
||||
|
||||
// AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet
|
||||
type AutoscalingRunnerSetStatus struct {
|
||||
// +optional
|
||||
CurrentRunners int `json:"currentRunners"`
|
||||
|
||||
// +optional
|
||||
Phase AutoscalingRunnerSetPhase `json:"phase"`
|
||||
|
||||
// EphemeralRunner counts separated by the stage ephemeral runners are in, taken from the EphemeralRunnerSet
|
||||
|
||||
// +optional
|
||||
PendingEphemeralRunners int `json:"pendingEphemeralRunners"`
|
||||
// +optional
|
||||
RunningEphemeralRunners int `json:"runningEphemeralRunners"`
|
||||
// +optional
|
||||
FailedEphemeralRunners int `json:"failedEphemeralRunners"`
|
||||
}
|
||||
|
||||
type AutoscalingRunnerSetPhase string
|
||||
|
||||
@@ -37,14 +37,6 @@ type EphemeralRunnerSetSpec struct {
|
||||
|
||||
// EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet
|
||||
type EphemeralRunnerSetStatus struct {
|
||||
// CurrentReplicas is the number of currently running EphemeralRunner resources being managed by this EphemeralRunnerSet.
|
||||
CurrentReplicas int `json:"currentReplicas"`
|
||||
// +optional
|
||||
PendingEphemeralRunners int `json:"pendingEphemeralRunners"`
|
||||
// +optional
|
||||
RunningEphemeralRunners int `json:"runningEphemeralRunners"`
|
||||
// +optional
|
||||
FailedEphemeralRunners int `json:"failedEphemeralRunners"`
|
||||
// +optional
|
||||
Phase EphemeralRunnerSetPhase `json:"phase"`
|
||||
}
|
||||
@@ -62,11 +54,6 @@ const (
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:printcolumn:JSONPath=".spec.replicas",name="DesiredReplicas",type="integer"
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.currentReplicas", name="CurrentReplicas",type="integer"
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.pendingEphemeralRunners",name=Pending Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.runningEphemeralRunners",name=Running Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.finishedEphemeralRunners",name=Finished Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.deletingEphemeralRunners",name=Deleting Runners,type=integer
|
||||
// +kubebuilder:printcolumn:JSONPath=".status.phase",name=Phase,type=string
|
||||
|
||||
// EphemeralRunnerSet is the Schema for the ephemeralrunnersets API
|
||||
|
||||
-23
@@ -21,24 +21,9 @@ spec:
|
||||
- jsonPath: .spec.maxRunners
|
||||
name: Maximum Runners
|
||||
type: integer
|
||||
- jsonPath: .status.currentRunners
|
||||
name: Current Runners
|
||||
type: integer
|
||||
- jsonPath: .status.phase
|
||||
name: Phase
|
||||
type: string
|
||||
- jsonPath: .status.pendingEphemeralRunners
|
||||
name: Pending Runners
|
||||
type: integer
|
||||
- jsonPath: .status.runningEphemeralRunners
|
||||
name: Running Runners
|
||||
type: integer
|
||||
- jsonPath: .status.finishedEphemeralRunners
|
||||
name: Finished Runners
|
||||
type: integer
|
||||
- jsonPath: .status.deletingEphemeralRunners
|
||||
name: Deleting Runners
|
||||
type: integer
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
@@ -16543,16 +16528,8 @@ spec:
|
||||
status:
|
||||
description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet
|
||||
properties:
|
||||
currentRunners:
|
||||
type: integer
|
||||
failedEphemeralRunners:
|
||||
type: integer
|
||||
pendingEphemeralRunners:
|
||||
type: integer
|
||||
phase:
|
||||
type: string
|
||||
runningEphemeralRunners:
|
||||
type: integer
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
|
||||
-26
@@ -18,21 +18,6 @@ spec:
|
||||
- jsonPath: .spec.replicas
|
||||
name: DesiredReplicas
|
||||
type: integer
|
||||
- jsonPath: .status.currentReplicas
|
||||
name: CurrentReplicas
|
||||
type: integer
|
||||
- jsonPath: .status.pendingEphemeralRunners
|
||||
name: Pending Runners
|
||||
type: integer
|
||||
- jsonPath: .status.runningEphemeralRunners
|
||||
name: Running Runners
|
||||
type: integer
|
||||
- jsonPath: .status.finishedEphemeralRunners
|
||||
name: Finished Runners
|
||||
type: integer
|
||||
- jsonPath: .status.deletingEphemeralRunners
|
||||
name: Deleting Runners
|
||||
type: integer
|
||||
- jsonPath: .status.phase
|
||||
name: Phase
|
||||
type: string
|
||||
@@ -8316,20 +8301,9 @@ spec:
|
||||
status:
|
||||
description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet
|
||||
properties:
|
||||
currentReplicas:
|
||||
description: CurrentReplicas is the number of currently running EphemeralRunner resources being managed by this EphemeralRunnerSet.
|
||||
type: integer
|
||||
failedEphemeralRunners:
|
||||
type: integer
|
||||
pendingEphemeralRunners:
|
||||
type: integer
|
||||
phase:
|
||||
description: EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource
|
||||
type: string
|
||||
runningEphemeralRunners:
|
||||
type: integer
|
||||
required:
|
||||
- currentReplicas
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
|
||||
-23
@@ -21,24 +21,9 @@ spec:
|
||||
- jsonPath: .spec.maxRunners
|
||||
name: Maximum Runners
|
||||
type: integer
|
||||
- jsonPath: .status.currentRunners
|
||||
name: Current Runners
|
||||
type: integer
|
||||
- jsonPath: .status.phase
|
||||
name: Phase
|
||||
type: string
|
||||
- jsonPath: .status.pendingEphemeralRunners
|
||||
name: Pending Runners
|
||||
type: integer
|
||||
- jsonPath: .status.runningEphemeralRunners
|
||||
name: Running Runners
|
||||
type: integer
|
||||
- jsonPath: .status.finishedEphemeralRunners
|
||||
name: Finished Runners
|
||||
type: integer
|
||||
- jsonPath: .status.deletingEphemeralRunners
|
||||
name: Deleting Runners
|
||||
type: integer
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
@@ -16543,16 +16528,8 @@ spec:
|
||||
status:
|
||||
description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet
|
||||
properties:
|
||||
currentRunners:
|
||||
type: integer
|
||||
failedEphemeralRunners:
|
||||
type: integer
|
||||
pendingEphemeralRunners:
|
||||
type: integer
|
||||
phase:
|
||||
type: string
|
||||
runningEphemeralRunners:
|
||||
type: integer
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
|
||||
-26
@@ -18,21 +18,6 @@ spec:
|
||||
- jsonPath: .spec.replicas
|
||||
name: DesiredReplicas
|
||||
type: integer
|
||||
- jsonPath: .status.currentReplicas
|
||||
name: CurrentReplicas
|
||||
type: integer
|
||||
- jsonPath: .status.pendingEphemeralRunners
|
||||
name: Pending Runners
|
||||
type: integer
|
||||
- jsonPath: .status.runningEphemeralRunners
|
||||
name: Running Runners
|
||||
type: integer
|
||||
- jsonPath: .status.finishedEphemeralRunners
|
||||
name: Finished Runners
|
||||
type: integer
|
||||
- jsonPath: .status.deletingEphemeralRunners
|
||||
name: Deleting Runners
|
||||
type: integer
|
||||
- jsonPath: .status.phase
|
||||
name: Phase
|
||||
type: string
|
||||
@@ -8316,20 +8301,9 @@ spec:
|
||||
status:
|
||||
description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet
|
||||
properties:
|
||||
currentReplicas:
|
||||
description: CurrentReplicas is the number of currently running EphemeralRunner resources being managed by this EphemeralRunnerSet.
|
||||
type: integer
|
||||
failedEphemeralRunners:
|
||||
type: integer
|
||||
pendingEphemeralRunners:
|
||||
type: integer
|
||||
phase:
|
||||
description: EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource
|
||||
type: string
|
||||
runningEphemeralRunners:
|
||||
type: integer
|
||||
required:
|
||||
- currentReplicas
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
|
||||
@@ -21,24 +21,9 @@ spec:
|
||||
- jsonPath: .spec.maxRunners
|
||||
name: Maximum Runners
|
||||
type: integer
|
||||
- jsonPath: .status.currentRunners
|
||||
name: Current Runners
|
||||
type: integer
|
||||
- jsonPath: .status.phase
|
||||
name: Phase
|
||||
type: string
|
||||
- jsonPath: .status.pendingEphemeralRunners
|
||||
name: Pending Runners
|
||||
type: integer
|
||||
- jsonPath: .status.runningEphemeralRunners
|
||||
name: Running Runners
|
||||
type: integer
|
||||
- jsonPath: .status.finishedEphemeralRunners
|
||||
name: Finished Runners
|
||||
type: integer
|
||||
- jsonPath: .status.deletingEphemeralRunners
|
||||
name: Deleting Runners
|
||||
type: integer
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
@@ -16543,16 +16528,8 @@ spec:
|
||||
status:
|
||||
description: AutoscalingRunnerSetStatus defines the observed state of AutoscalingRunnerSet
|
||||
properties:
|
||||
currentRunners:
|
||||
type: integer
|
||||
failedEphemeralRunners:
|
||||
type: integer
|
||||
pendingEphemeralRunners:
|
||||
type: integer
|
||||
phase:
|
||||
type: string
|
||||
runningEphemeralRunners:
|
||||
type: integer
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
|
||||
@@ -18,21 +18,6 @@ spec:
|
||||
- jsonPath: .spec.replicas
|
||||
name: DesiredReplicas
|
||||
type: integer
|
||||
- jsonPath: .status.currentReplicas
|
||||
name: CurrentReplicas
|
||||
type: integer
|
||||
- jsonPath: .status.pendingEphemeralRunners
|
||||
name: Pending Runners
|
||||
type: integer
|
||||
- jsonPath: .status.runningEphemeralRunners
|
||||
name: Running Runners
|
||||
type: integer
|
||||
- jsonPath: .status.finishedEphemeralRunners
|
||||
name: Finished Runners
|
||||
type: integer
|
||||
- jsonPath: .status.deletingEphemeralRunners
|
||||
name: Deleting Runners
|
||||
type: integer
|
||||
- jsonPath: .status.phase
|
||||
name: Phase
|
||||
type: string
|
||||
@@ -8316,20 +8301,9 @@ spec:
|
||||
status:
|
||||
description: EphemeralRunnerSetStatus defines the observed state of EphemeralRunnerSet
|
||||
properties:
|
||||
currentReplicas:
|
||||
description: CurrentReplicas is the number of currently running EphemeralRunner resources being managed by this EphemeralRunnerSet.
|
||||
type: integer
|
||||
failedEphemeralRunners:
|
||||
type: integer
|
||||
pendingEphemeralRunners:
|
||||
type: integer
|
||||
phase:
|
||||
description: EphemeralRunnerSetPhase is the phase of the ephemeral runner set resource
|
||||
type: string
|
||||
runningEphemeralRunners:
|
||||
type: integer
|
||||
required:
|
||||
- currentReplicas
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
|
||||
@@ -295,7 +295,19 @@ func (r *AutoscalingRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl
|
||||
// When runners are actively processing jobs, defer the spec update:
|
||||
// delete the listener to stop accepting new jobs, but leave the ERS
|
||||
// (and its running pods) untouched until all jobs have drained.
|
||||
if ephemeralRunnerSet.Status.RunningEphemeralRunners+ephemeralRunnerSet.Status.PendingEphemeralRunners > 0 {
|
||||
var ephemeralRunnerList v1alpha1.EphemeralRunnerList
|
||||
if err := r.List(ctx, &ephemeralRunnerList,
|
||||
client.InNamespace(ephemeralRunnerSet.Namespace),
|
||||
client.MatchingFields{resourceOwnerKey: ephemeralRunnerSet.Name},
|
||||
); err != nil {
|
||||
log.Error(err, "Failed to list ephemeral runners")
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
|
||||
buckets := AggregateEphemeralRunnerLifecycle(ephemeralRunnerList.Items)
|
||||
activeRunners := buckets.Pending + buckets.Running
|
||||
|
||||
if activeRunners > 0 {
|
||||
log.Info("Ephemeral runner set spec changed but runners are still active; deleting listener to stop new jobs")
|
||||
if _, err := r.cleanupListener(ctx, &autoscalingRunnerSet, log); err != nil {
|
||||
log.Error(err, "Failed to clean up listener while waiting for runners to drain")
|
||||
@@ -433,23 +445,13 @@ func (r *AutoscalingRunnerSetReconciler) cleanUpResources(ctx context.Context, a
|
||||
|
||||
// Update the status of autoscaling runner set if necessary
|
||||
func (r *AutoscalingRunnerSetReconciler) updateStatus(ctx context.Context, autoscalingRunnerSet *v1alpha1.AutoscalingRunnerSet, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, phase v1alpha1.AutoscalingRunnerSetPhase, log logr.Logger) error {
|
||||
countDiff := ephemeralRunnerSet != nil && ephemeralRunnerSet.Status.CurrentReplicas != autoscalingRunnerSet.Status.CurrentRunners
|
||||
phaseDiff := phase != autoscalingRunnerSet.Status.Phase
|
||||
if !countDiff && !phaseDiff {
|
||||
if !phaseDiff {
|
||||
return nil
|
||||
}
|
||||
|
||||
original := autoscalingRunnerSet.DeepCopy()
|
||||
if phaseDiff {
|
||||
autoscalingRunnerSet.Status.Phase = phase
|
||||
}
|
||||
|
||||
if countDiff && ephemeralRunnerSet != nil {
|
||||
autoscalingRunnerSet.Status.CurrentRunners = ephemeralRunnerSet.Status.CurrentReplicas
|
||||
autoscalingRunnerSet.Status.PendingEphemeralRunners = ephemeralRunnerSet.Status.PendingEphemeralRunners
|
||||
autoscalingRunnerSet.Status.RunningEphemeralRunners = ephemeralRunnerSet.Status.RunningEphemeralRunners
|
||||
autoscalingRunnerSet.Status.FailedEphemeralRunners = ephemeralRunnerSet.Status.FailedEphemeralRunners
|
||||
}
|
||||
autoscalingRunnerSet.Status.Phase = phase
|
||||
|
||||
if err := r.Status().Patch(ctx, autoscalingRunnerSet, client.MergeFrom(original)); err != nil {
|
||||
log.Error(err, "Failed to patch autoscaling runner set status")
|
||||
|
||||
@@ -871,17 +871,10 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() {
|
||||
// Emulate running and pending jobs
|
||||
runnerSet := runnerSetList.Items[0]
|
||||
activeRunnerSet := runnerSet.DeepCopy()
|
||||
activeRunnerSet.Status.CurrentReplicas = 6
|
||||
activeRunnerSet.Status.FailedEphemeralRunners = 1
|
||||
activeRunnerSet.Status.RunningEphemeralRunners = 2
|
||||
activeRunnerSet.Status.PendingEphemeralRunners = 3
|
||||
activeRunnerSet.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning
|
||||
|
||||
desiredStatus := v1alpha1.AutoscalingRunnerSetStatus{
|
||||
CurrentRunners: activeRunnerSet.Status.CurrentReplicas,
|
||||
Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning,
|
||||
PendingEphemeralRunners: activeRunnerSet.Status.PendingEphemeralRunners,
|
||||
RunningEphemeralRunners: activeRunnerSet.Status.RunningEphemeralRunners,
|
||||
FailedEphemeralRunners: activeRunnerSet.Status.FailedEphemeralRunners,
|
||||
Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning,
|
||||
}
|
||||
|
||||
err = k8sClient.Status().Patch(ctx, activeRunnerSet, client.MergeFrom(&runnerSet))
|
||||
@@ -978,17 +971,10 @@ var _ = Describe("Test AutoScalingRunnerSet controller", Ordered, func() {
|
||||
|
||||
runnerSet := runnerSetList.Items[0]
|
||||
statusUpdate := runnerSet.DeepCopy()
|
||||
statusUpdate.Status.CurrentReplicas = 6
|
||||
statusUpdate.Status.FailedEphemeralRunners = 1
|
||||
statusUpdate.Status.RunningEphemeralRunners = 2
|
||||
statusUpdate.Status.PendingEphemeralRunners = 3
|
||||
statusUpdate.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseRunning
|
||||
|
||||
desiredStatus := v1alpha1.AutoscalingRunnerSetStatus{
|
||||
CurrentRunners: statusUpdate.Status.CurrentReplicas,
|
||||
Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning,
|
||||
PendingEphemeralRunners: statusUpdate.Status.PendingEphemeralRunners,
|
||||
RunningEphemeralRunners: statusUpdate.Status.RunningEphemeralRunners,
|
||||
FailedEphemeralRunners: statusUpdate.Status.FailedEphemeralRunners,
|
||||
Phase: v1alpha1.AutoscalingRunnerSetPhaseRunning,
|
||||
}
|
||||
|
||||
err := k8sClient.Status().Patch(ctx, statusUpdate, client.MergeFrom(&runnerSet))
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"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/scaleset"
|
||||
"github.com/go-logr/logr"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
@@ -46,8 +47,9 @@ const (
|
||||
// EphemeralRunnerReconciler reconciles a EphemeralRunner object
|
||||
type EphemeralRunnerReconciler struct {
|
||||
client.Client
|
||||
Log logr.Logger
|
||||
Scheme *runtime.Scheme
|
||||
Log logr.Logger
|
||||
Scheme *runtime.Scheme
|
||||
PublishMetrics bool
|
||||
ResourceBuilder
|
||||
}
|
||||
|
||||
@@ -86,6 +88,8 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ
|
||||
original := ephemeralRunner.DeepCopy()
|
||||
|
||||
if !ephemeralRunner.DeletionTimestamp.IsZero() {
|
||||
r.emitLifecycleMetrics(ctx, &ephemeralRunner, log)
|
||||
|
||||
if !controllerutil.ContainsFinalizer(&ephemeralRunner, ephemeralRunnerFinalizerName) {
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
@@ -584,6 +588,8 @@ func (r *EphemeralRunnerReconciler) markAsFailed(ctx context.Context, ephemeralR
|
||||
return fmt.Errorf("failed to update ephemeral runner status Phase/Message: %w", err)
|
||||
}
|
||||
|
||||
r.emitLifecycleMetrics(ctx, ephemeralRunner, log)
|
||||
|
||||
log.Info("Removing the runner from the service")
|
||||
if err := r.deleteRunnerFromService(ctx, ephemeralRunner, log); err != nil {
|
||||
return fmt.Errorf("failed to remove the runner from service: %w", err)
|
||||
@@ -605,6 +611,8 @@ func (r *EphemeralRunnerReconciler) markAsOutdated(ctx context.Context, ephemera
|
||||
return fmt.Errorf("failed to update ephemeral runner status Phase/Message: %w", err)
|
||||
}
|
||||
|
||||
r.emitLifecycleMetrics(ctx, ephemeralRunner, log)
|
||||
|
||||
log.Info("Removing the runner from the service")
|
||||
if err := r.deleteRunnerFromService(ctx, ephemeralRunner, log); err != nil {
|
||||
return fmt.Errorf("failed to remove the runner from service: %w", err)
|
||||
@@ -638,6 +646,8 @@ func (r *EphemeralRunnerReconciler) deletePodAsFailed(ctx context.Context, ephem
|
||||
return fmt.Errorf("failed to update ephemeral runner status with failure count: %w", err)
|
||||
}
|
||||
|
||||
r.emitLifecycleMetrics(ctx, ephemeralRunner, log)
|
||||
|
||||
log.Info("EphemeralRunner pod is deleted and status is updated with failure count")
|
||||
return nil
|
||||
}
|
||||
@@ -836,6 +846,8 @@ func (r *EphemeralRunnerReconciler) updateRunStatusFromPod(ctx context.Context,
|
||||
return fmt.Errorf("failed to update runner status for Phase/Reason/Message/Ready: %w", err)
|
||||
}
|
||||
|
||||
r.emitLifecycleMetrics(ctx, ephemeralRunner, log)
|
||||
|
||||
log.Info("Updated ephemeral runner status")
|
||||
return nil
|
||||
}
|
||||
@@ -888,3 +900,77 @@ func initContainerFailed(pod *corev1.Pod) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// emitLifecycleMetrics recomputes and emits lifecycle metrics for all EphemeralRunners
|
||||
// owned by the same EphemeralRunnerSet as the given runner. This ensures metrics reflect
|
||||
// the current lifecycle state of all sibling runners under the same AutoscalingRunnerSet.
|
||||
//
|
||||
// Label values are derived from the runner's labels (LabelKeyGitHubScaleSetName, etc.).
|
||||
// If required labels are missing, logs a warning and skips metric emission.
|
||||
func (r *EphemeralRunnerReconciler) emitLifecycleMetrics(ctx context.Context, ephemeralRunner *v1alpha1.EphemeralRunner, log logr.Logger) {
|
||||
if !r.PublishMetrics {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract owner EphemeralRunnerSet from controller owner reference
|
||||
ownerRef := metav1.GetControllerOfNoCopy(ephemeralRunner)
|
||||
if ownerRef == nil || ownerRef.Kind != "EphemeralRunnerSet" {
|
||||
log.V(1).Info("EphemeralRunner has no EphemeralRunnerSet owner, skipping metric emission")
|
||||
return
|
||||
}
|
||||
|
||||
// List all sibling EphemeralRunners owned by the same EphemeralRunnerSet
|
||||
var runnerList v1alpha1.EphemeralRunnerList
|
||||
if err := r.List(ctx, &runnerList,
|
||||
client.InNamespace(ephemeralRunner.Namespace),
|
||||
client.MatchingFields{resourceOwnerKey: ownerRef.Name},
|
||||
); err != nil {
|
||||
log.Error(err, "Failed to list sibling EphemeralRunners for metric emission")
|
||||
return
|
||||
}
|
||||
|
||||
// Aggregate lifecycle counts using the helper from Task 3
|
||||
buckets := AggregateEphemeralRunnerLifecycle(runnerList.Items)
|
||||
|
||||
// Extract label values from the runner (all siblings share the same labels)
|
||||
name := ephemeralRunner.Labels[LabelKeyGitHubScaleSetName]
|
||||
namespace := ephemeralRunner.Labels[LabelKeyGitHubScaleSetNamespace]
|
||||
repository := ephemeralRunner.Labels[LabelKeyGitHubRepository]
|
||||
organization := ephemeralRunner.Labels[LabelKeyGitHubOrganization]
|
||||
enterprise := ephemeralRunner.Labels[LabelKeyGitHubEnterprise]
|
||||
|
||||
// Gracefully handle missing labels: log warning and skip if name/namespace empty
|
||||
if name == "" || namespace == "" {
|
||||
log.Info("Missing required labels (name/namespace) for metric emission, skipping",
|
||||
"name", name,
|
||||
"namespace", namespace,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Emit all six lifecycle metrics
|
||||
metrics.SetEphemeralRunnerCountsByLifecycle(
|
||||
metrics.CommonLabels{
|
||||
Name: name,
|
||||
Namespace: namespace,
|
||||
Repository: repository,
|
||||
Organization: organization,
|
||||
Enterprise: enterprise,
|
||||
},
|
||||
buckets.Pending,
|
||||
buckets.Running,
|
||||
buckets.Succeeded,
|
||||
buckets.Failed,
|
||||
buckets.Outdated,
|
||||
buckets.Deleting,
|
||||
)
|
||||
|
||||
log.V(1).Info("Emitted lifecycle metrics",
|
||||
"pending", buckets.Pending,
|
||||
"running", buckets.Running,
|
||||
"succeeded", buckets.Succeeded,
|
||||
"failed", buckets.Failed,
|
||||
"outdated", buckets.Outdated,
|
||||
"deleting", buckets.Deleting,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package actionsgithubcom
|
||||
|
||||
import (
|
||||
v1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
|
||||
)
|
||||
|
||||
// EphemeralRunnerLifecycleBuckets represents the counts of ephemeral runners
|
||||
// in each lifecycle state according to the precedence contract:
|
||||
// 1. deleting if DeletionTimestamp != nil
|
||||
// 2. Otherwise by Status.Phase (Running/Succeeded/Failed/Outdated)
|
||||
// 3. Fallback to pending for empty/unset/other
|
||||
type EphemeralRunnerLifecycleBuckets struct {
|
||||
Pending int
|
||||
Running int
|
||||
Succeeded int
|
||||
Failed int
|
||||
Outdated int
|
||||
Deleting int
|
||||
}
|
||||
|
||||
// AggregateEphemeralRunnerLifecycle classifies a list of EphemeralRunners into
|
||||
// lifecycle buckets using deterministic precedence to ensure each runner is counted
|
||||
// in exactly one bucket. This helper is independent from EphemeralRunnerSet-specific
|
||||
// structures and can be called directly by any controller managing EphemeralRunners.
|
||||
//
|
||||
// Precedence contract:
|
||||
// 1. deleting if DeletionTimestamp != nil (takes precedence over all phases)
|
||||
// 2. Otherwise by Status.Phase: Running/Succeeded/Failed/Outdated
|
||||
// 3. Fallback to pending for empty/unset/other phases
|
||||
func AggregateEphemeralRunnerLifecycle(runners []v1alpha1.EphemeralRunner) EphemeralRunnerLifecycleBuckets {
|
||||
var buckets EphemeralRunnerLifecycleBuckets
|
||||
|
||||
for i := range runners {
|
||||
r := &runners[i]
|
||||
|
||||
// Precedence 1: DeletionTimestamp takes precedence over all phases
|
||||
if !r.DeletionTimestamp.IsZero() {
|
||||
buckets.Deleting++
|
||||
continue
|
||||
}
|
||||
|
||||
// Precedence 2: Classify by Status.Phase
|
||||
switch r.Status.Phase {
|
||||
case v1alpha1.EphemeralRunnerPhaseRunning:
|
||||
buckets.Running++
|
||||
case v1alpha1.EphemeralRunnerPhaseSucceeded:
|
||||
buckets.Succeeded++
|
||||
case v1alpha1.EphemeralRunnerPhaseFailed:
|
||||
buckets.Failed++
|
||||
case v1alpha1.EphemeralRunnerPhaseOutdated:
|
||||
buckets.Outdated++
|
||||
default:
|
||||
// Precedence 3: Fallback to pending for empty/unset/other phases
|
||||
// This includes EphemeralRunnerPhasePending and any unset or unrecognized phases
|
||||
buckets.Pending++
|
||||
}
|
||||
}
|
||||
|
||||
return buckets
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
package actionsgithubcom
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
v1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
func TestAggregateEphemeralRunnerLifecycle(t *testing.T) {
|
||||
now := metav1.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
runners []v1alpha1.EphemeralRunner
|
||||
expected EphemeralRunnerLifecycleBuckets
|
||||
}{
|
||||
{
|
||||
name: "empty list",
|
||||
runners: []v1alpha1.EphemeralRunner{},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 0, Running: 0, Succeeded: 0, Failed: 0, Outdated: 0, Deleting: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all phases without deletion timestamp",
|
||||
runners: []v1alpha1.EphemeralRunner{
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhasePending}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseSucceeded}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseFailed}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseOutdated}},
|
||||
},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 1, Running: 1, Succeeded: 1, Failed: 1, Outdated: 1, Deleting: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty phase defaults to pending",
|
||||
runners: []v1alpha1.EphemeralRunner{
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: ""}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{}},
|
||||
},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 2, Running: 0, Succeeded: 0, Failed: 0, Outdated: 0, Deleting: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unrecognized phase defaults to pending",
|
||||
runners: []v1alpha1.EphemeralRunner{
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: "UnknownPhase"}},
|
||||
},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 1, Running: 0, Succeeded: 0, Failed: 0, Outdated: 0, Deleting: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deletion timestamp takes precedence over all phases",
|
||||
runners: []v1alpha1.EphemeralRunner{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhasePending},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseSucceeded},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseFailed},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseOutdated},
|
||||
},
|
||||
},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 0, Running: 0, Succeeded: 0, Failed: 0, Outdated: 0, Deleting: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "edge case: running phase with deletion timestamp goes to deleting",
|
||||
runners: []v1alpha1.EphemeralRunner{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning},
|
||||
},
|
||||
},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 0, Running: 0, Succeeded: 0, Failed: 0, Outdated: 0, Deleting: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed runners with and without deletion timestamp",
|
||||
runners: []v1alpha1.EphemeralRunner{
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning},
|
||||
},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseFailed}},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseFailed},
|
||||
},
|
||||
},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 0, Running: 1, Succeeded: 0, Failed: 1, Outdated: 0, Deleting: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multiple runners in same phase",
|
||||
runners: []v1alpha1.EphemeralRunner{
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 0, Running: 3, Succeeded: 0, Failed: 0, Outdated: 0, Deleting: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deletion timestamp with empty phase goes to deleting",
|
||||
runners: []v1alpha1.EphemeralRunner{
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: ""},
|
||||
},
|
||||
},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 0, Running: 0, Succeeded: 0, Failed: 0, Outdated: 0, Deleting: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "comprehensive mixed scenario",
|
||||
runners: []v1alpha1.EphemeralRunner{
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhasePending}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhasePending}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseSucceeded}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseFailed}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseFailed}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseOutdated}},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning},
|
||||
},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseSucceeded},
|
||||
},
|
||||
},
|
||||
expected: EphemeralRunnerLifecycleBuckets{
|
||||
Pending: 2, Running: 3, Succeeded: 1, Failed: 2, Outdated: 1, Deleting: 2,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := AggregateEphemeralRunnerLifecycle(tt.runners)
|
||||
|
||||
if result.Pending != tt.expected.Pending {
|
||||
t.Errorf("Pending count mismatch: got %d, want %d", result.Pending, tt.expected.Pending)
|
||||
}
|
||||
if result.Running != tt.expected.Running {
|
||||
t.Errorf("Running count mismatch: got %d, want %d", result.Running, tt.expected.Running)
|
||||
}
|
||||
if result.Succeeded != tt.expected.Succeeded {
|
||||
t.Errorf("Succeeded count mismatch: got %d, want %d", result.Succeeded, tt.expected.Succeeded)
|
||||
}
|
||||
if result.Failed != tt.expected.Failed {
|
||||
t.Errorf("Failed count mismatch: got %d, want %d", result.Failed, tt.expected.Failed)
|
||||
}
|
||||
if result.Outdated != tt.expected.Outdated {
|
||||
t.Errorf("Outdated count mismatch: got %d, want %d", result.Outdated, tt.expected.Outdated)
|
||||
}
|
||||
if result.Deleting != tt.expected.Deleting {
|
||||
t.Errorf("Deleting count mismatch: got %d, want %d", result.Deleting, tt.expected.Deleting)
|
||||
}
|
||||
|
||||
total := result.Pending + result.Running + result.Succeeded + result.Failed + result.Outdated + result.Deleting
|
||||
if total != len(tt.runners) {
|
||||
t.Errorf("Total count mismatch: counted %d runners but input had %d runners", total, len(tt.runners))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAggregateEphemeralRunnerLifecycle_EdgePrecedence(t *testing.T) {
|
||||
now := metav1.Now()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
phase v1alpha1.EphemeralRunnerPhase
|
||||
hasDeletionTime bool
|
||||
expectedBucket string
|
||||
}{
|
||||
{
|
||||
name: "Running without deletion timestamp",
|
||||
phase: v1alpha1.EphemeralRunnerPhaseRunning,
|
||||
hasDeletionTime: false,
|
||||
expectedBucket: "running",
|
||||
},
|
||||
{
|
||||
name: "Running with deletion timestamp goes to deleting",
|
||||
phase: v1alpha1.EphemeralRunnerPhaseRunning,
|
||||
hasDeletionTime: true,
|
||||
expectedBucket: "deleting",
|
||||
},
|
||||
{
|
||||
name: "Succeeded without deletion timestamp",
|
||||
phase: v1alpha1.EphemeralRunnerPhaseSucceeded,
|
||||
hasDeletionTime: false,
|
||||
expectedBucket: "succeeded",
|
||||
},
|
||||
{
|
||||
name: "Succeeded with deletion timestamp goes to deleting",
|
||||
phase: v1alpha1.EphemeralRunnerPhaseSucceeded,
|
||||
hasDeletionTime: true,
|
||||
expectedBucket: "deleting",
|
||||
},
|
||||
{
|
||||
name: "Failed without deletion timestamp",
|
||||
phase: v1alpha1.EphemeralRunnerPhaseFailed,
|
||||
hasDeletionTime: false,
|
||||
expectedBucket: "failed",
|
||||
},
|
||||
{
|
||||
name: "Failed with deletion timestamp goes to deleting",
|
||||
phase: v1alpha1.EphemeralRunnerPhaseFailed,
|
||||
hasDeletionTime: true,
|
||||
expectedBucket: "deleting",
|
||||
},
|
||||
{
|
||||
name: "Outdated without deletion timestamp",
|
||||
phase: v1alpha1.EphemeralRunnerPhaseOutdated,
|
||||
hasDeletionTime: false,
|
||||
expectedBucket: "outdated",
|
||||
},
|
||||
{
|
||||
name: "Outdated with deletion timestamp goes to deleting",
|
||||
phase: v1alpha1.EphemeralRunnerPhaseOutdated,
|
||||
hasDeletionTime: true,
|
||||
expectedBucket: "deleting",
|
||||
},
|
||||
{
|
||||
name: "Pending without deletion timestamp",
|
||||
phase: v1alpha1.EphemeralRunnerPhasePending,
|
||||
hasDeletionTime: false,
|
||||
expectedBucket: "pending",
|
||||
},
|
||||
{
|
||||
name: "Pending with deletion timestamp goes to deleting",
|
||||
phase: v1alpha1.EphemeralRunnerPhasePending,
|
||||
hasDeletionTime: true,
|
||||
expectedBucket: "deleting",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runner := v1alpha1.EphemeralRunner{
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: tt.phase},
|
||||
}
|
||||
if tt.hasDeletionTime {
|
||||
runner.DeletionTimestamp = &now
|
||||
}
|
||||
|
||||
result := AggregateEphemeralRunnerLifecycle([]v1alpha1.EphemeralRunner{runner})
|
||||
|
||||
var foundBucket string
|
||||
if result.Pending == 1 {
|
||||
foundBucket = "pending"
|
||||
} else if result.Running == 1 {
|
||||
foundBucket = "running"
|
||||
} else if result.Succeeded == 1 {
|
||||
foundBucket = "succeeded"
|
||||
} else if result.Failed == 1 {
|
||||
foundBucket = "failed"
|
||||
} else if result.Outdated == 1 {
|
||||
foundBucket = "outdated"
|
||||
} else if result.Deleting == 1 {
|
||||
foundBucket = "deleting"
|
||||
}
|
||||
|
||||
if foundBucket != tt.expectedBucket {
|
||||
t.Errorf("Expected runner to be in %s bucket, but found in %s bucket", tt.expectedBucket, foundBucket)
|
||||
}
|
||||
|
||||
total := result.Pending + result.Running + result.Succeeded + result.Failed + result.Outdated + result.Deleting
|
||||
if total != 1 {
|
||||
t.Errorf("Expected exactly 1 runner to be counted, but got %d", total)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -27,9 +27,7 @@ import (
|
||||
"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"
|
||||
"go.uber.org/multierr"
|
||||
@@ -205,29 +203,6 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R
|
||||
"deleting", len(ephemeralRunnersByState.deleting),
|
||||
)
|
||||
|
||||
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.SetEphemeralRunnerCountsByStatus(
|
||||
metrics.CommonLabels{
|
||||
Name: ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetName],
|
||||
Namespace: ephemeralRunnerSet.Labels[LabelKeyGitHubScaleSetNamespace],
|
||||
Repository: parsedURL.Repository,
|
||||
Organization: parsedURL.Organization,
|
||||
Enterprise: parsedURL.Enterprise,
|
||||
},
|
||||
len(ephemeralRunnersByState.pending),
|
||||
len(ephemeralRunnersByState.running),
|
||||
len(ephemeralRunnersByState.failed),
|
||||
)
|
||||
}
|
||||
|
||||
total := ephemeralRunnersByState.scaleTotal()
|
||||
if ephemeralRunnerSet.Spec.PatchID == 0 || ephemeralRunnerSet.Spec.PatchID != ephemeralRunnersByState.latestPatchID {
|
||||
defer func() {
|
||||
@@ -272,7 +247,6 @@ func (r *EphemeralRunnerSetReconciler) Reconcile(ctx context.Context, req ctrl.R
|
||||
|
||||
func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemeralRunnerSet *v1alpha1.EphemeralRunnerSet, state *ephemeralRunnersByState, log logr.Logger) error {
|
||||
original := ephemeralRunnerSet.DeepCopy()
|
||||
total := state.scaleTotal()
|
||||
var phase v1alpha1.EphemeralRunnerSetPhase
|
||||
switch {
|
||||
case len(state.outdated) > 0:
|
||||
@@ -283,11 +257,7 @@ func (r *EphemeralRunnerSetReconciler) updateStatus(ctx context.Context, ephemer
|
||||
phase = ephemeralRunnerSet.Status.Phase
|
||||
}
|
||||
desiredStatus := v1alpha1.EphemeralRunnerSetStatus{
|
||||
CurrentReplicas: total,
|
||||
Phase: phase,
|
||||
PendingEphemeralRunners: len(state.pending),
|
||||
RunningEphemeralRunners: len(state.running),
|
||||
FailedEphemeralRunners: len(state.failed),
|
||||
Phase: phase,
|
||||
}
|
||||
|
||||
// Update the status if needed.
|
||||
|
||||
@@ -136,17 +136,16 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() {
|
||||
// Check if the status stay 0
|
||||
Consistently(
|
||||
func() (int, error) {
|
||||
runnerSet := new(v1alpha1.EphemeralRunnerSet)
|
||||
err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, runnerSet)
|
||||
var runnerList v1alpha1.EphemeralRunnerList
|
||||
err := k8sClient.List(ctx, &runnerList, client.InNamespace(ephemeralRunnerSet.Namespace), client.MatchingFields{resourceOwnerKey: ephemeralRunnerSet.Name})
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
return int(runnerSet.Status.CurrentReplicas), nil
|
||||
return len(runnerList.Items), nil
|
||||
},
|
||||
ephemeralRunnerSetTestTimeout,
|
||||
ephemeralRunnerSetTestInterval,
|
||||
).Should(BeEquivalentTo(0), "EphemeralRunnerSet status should be 0")
|
||||
).Should(BeEquivalentTo(0), "EphemeralRunnerSet should have 0 runners")
|
||||
|
||||
// Scaling up the EphemeralRunnerSet
|
||||
updated := created.DeepCopy()
|
||||
@@ -190,17 +189,16 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() {
|
||||
// Check if the status is updated
|
||||
Eventually(
|
||||
func() (int, error) {
|
||||
runnerSet := new(v1alpha1.EphemeralRunnerSet)
|
||||
err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunnerSet.Name, Namespace: ephemeralRunnerSet.Namespace}, runnerSet)
|
||||
var runnerList v1alpha1.EphemeralRunnerList
|
||||
err := k8sClient.List(ctx, &runnerList, client.InNamespace(ephemeralRunnerSet.Namespace), client.MatchingFields{resourceOwnerKey: ephemeralRunnerSet.Name})
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
|
||||
return int(runnerSet.Status.CurrentReplicas), nil
|
||||
return len(runnerList.Items), nil
|
||||
},
|
||||
ephemeralRunnerSetTestTimeout,
|
||||
ephemeralRunnerSetTestInterval,
|
||||
).Should(BeEquivalentTo(5), "EphemeralRunnerSet status should be 5")
|
||||
).Should(BeEquivalentTo(5), "EphemeralRunnerSet should have 5 runners")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1184,11 +1182,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() {
|
||||
).Should(BeTrue(), "Failed to eventually update to one pending, one running and one failed")
|
||||
|
||||
desiredStatus := v1alpha1.EphemeralRunnerSetStatus{
|
||||
Phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
||||
CurrentReplicas: 3,
|
||||
PendingEphemeralRunners: 1,
|
||||
RunningEphemeralRunners: 1,
|
||||
FailedEphemeralRunners: 1,
|
||||
Phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
||||
}
|
||||
Eventually(
|
||||
func() (v1alpha1.EphemeralRunnerSetStatus, error) {
|
||||
@@ -1227,11 +1221,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() {
|
||||
).Should(BeEquivalentTo(1), "Failed to eventually scale down")
|
||||
|
||||
desiredStatus = v1alpha1.EphemeralRunnerSetStatus{
|
||||
CurrentReplicas: 1,
|
||||
PendingEphemeralRunners: 0,
|
||||
RunningEphemeralRunners: 0,
|
||||
FailedEphemeralRunners: 1,
|
||||
Phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
||||
Phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
||||
}
|
||||
|
||||
Eventually(
|
||||
@@ -1251,11 +1241,7 @@ var _ = Describe("Test EphemeralRunnerSet controller", func() {
|
||||
Expect(err).To(BeNil(), "Failed to delete failed ephemeral runner")
|
||||
|
||||
desiredStatus = v1alpha1.EphemeralRunnerSetStatus{
|
||||
CurrentReplicas: 0,
|
||||
PendingEphemeralRunners: 0,
|
||||
RunningEphemeralRunners: 0,
|
||||
FailedEphemeralRunners: 0,
|
||||
Phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
||||
Phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
||||
}
|
||||
Eventually(
|
||||
func() (v1alpha1.EphemeralRunnerSetStatus, error) {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package metrics_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
v1alpha1 "github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
|
||||
actionsgithubcom "github.com/actions/actions-runner-controller/controllers/actions.github.com"
|
||||
githubmetrics "github.com/actions/actions-runner-controller/controllers/actions.github.com/metrics"
|
||||
dto "github.com/prometheus/client_model/go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
controllerMetrics "sigs.k8s.io/controller-runtime/pkg/metrics"
|
||||
)
|
||||
|
||||
var registerMetricsOnce sync.Once
|
||||
|
||||
func TestEphemeralRunnerLifecycleBucketsSetPrometheusMetrics(t *testing.T) {
|
||||
registerMetricsOnce.Do(githubmetrics.RegisterMetrics)
|
||||
|
||||
now := metav1.Now()
|
||||
runners := []v1alpha1.EphemeralRunner{
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhasePending}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseSucceeded}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseFailed}},
|
||||
{Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseOutdated}},
|
||||
{
|
||||
ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &now},
|
||||
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseRunning},
|
||||
},
|
||||
}
|
||||
|
||||
buckets := actionsgithubcom.AggregateEphemeralRunnerLifecycle(runners)
|
||||
labels := githubmetrics.CommonLabels{
|
||||
Name: "lifecycle-metrics-test",
|
||||
Namespace: "default",
|
||||
Repository: "test/repo",
|
||||
Organization: "test-org",
|
||||
Enterprise: "test-enterprise",
|
||||
}
|
||||
|
||||
githubmetrics.SetEphemeralRunnerCountsByLifecycle(
|
||||
labels,
|
||||
buckets.Pending,
|
||||
buckets.Running,
|
||||
buckets.Succeeded,
|
||||
buckets.Failed,
|
||||
buckets.Outdated,
|
||||
buckets.Deleting,
|
||||
)
|
||||
|
||||
metricFamilies, err := controllerMetrics.Registry.Gather()
|
||||
require.NoError(t, err)
|
||||
|
||||
labelSet := commonLabelSet(labels)
|
||||
assertGatheredGauge(t, metricFamilies, "gha_controller_pending_ephemeral_runners", labelSet, 1)
|
||||
assertGatheredGauge(t, metricFamilies, "gha_controller_running_ephemeral_runners", labelSet, 2)
|
||||
assertGatheredGauge(t, metricFamilies, "gha_controller_succeeded_ephemeral_runners", labelSet, 1)
|
||||
assertGatheredGauge(t, metricFamilies, "gha_controller_failed_ephemeral_runners", labelSet, 1)
|
||||
assertGatheredGauge(t, metricFamilies, "gha_controller_outdated_ephemeral_runners", labelSet, 1)
|
||||
assertGatheredGauge(t, metricFamilies, "gha_controller_deleting_ephemeral_runners", labelSet, 1)
|
||||
}
|
||||
|
||||
func assertGatheredGauge(t *testing.T, metricFamilies []*dto.MetricFamily, metricName string, expectedLabels map[string]string, expectedValue float64) {
|
||||
t.Helper()
|
||||
|
||||
for _, family := range metricFamilies {
|
||||
if family.GetName() != metricName {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, metric := range family.GetMetric() {
|
||||
if !metricHasLabels(metric, expectedLabels) {
|
||||
continue
|
||||
}
|
||||
|
||||
require.NotNil(t, metric.Gauge, "metric %q should be a gauge", metricName)
|
||||
assert.Equal(t, expectedValue, metric.Gauge.GetValue(), "metric %q value mismatch", metricName)
|
||||
return
|
||||
}
|
||||
|
||||
require.Fail(t, fmt.Sprintf("metric %q with expected labels was not gathered", metricName))
|
||||
}
|
||||
|
||||
require.Fail(t, fmt.Sprintf("metric family %q was not gathered", metricName))
|
||||
}
|
||||
|
||||
func metricHasLabels(metric *dto.Metric, expectedLabels map[string]string) bool {
|
||||
actualLabels := make(map[string]string, len(metric.GetLabel()))
|
||||
for _, label := range metric.GetLabel() {
|
||||
actualLabels[label.GetName()] = label.GetValue()
|
||||
}
|
||||
|
||||
for name, expectedValue := range expectedLabels {
|
||||
if actualLabels[name] != expectedValue {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func commonLabelSet(labels githubmetrics.CommonLabels) map[string]string {
|
||||
return map[string]string{
|
||||
"name": labels.Name,
|
||||
"namespace": labels.Namespace,
|
||||
"repository": labels.Repository,
|
||||
"organization": labels.Organization,
|
||||
"enterprise": labels.Enterprise,
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,30 @@ var (
|
||||
},
|
||||
labels,
|
||||
)
|
||||
succeededEphemeralRunners = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Subsystem: githubScaleSetControllerSubsystem,
|
||||
Name: "succeeded_ephemeral_runners",
|
||||
Help: "Number of ephemeral runners in a succeeded state.",
|
||||
},
|
||||
labels,
|
||||
)
|
||||
outdatedEphemeralRunners = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Subsystem: githubScaleSetControllerSubsystem,
|
||||
Name: "outdated_ephemeral_runners",
|
||||
Help: "Number of ephemeral runners in an outdated state.",
|
||||
},
|
||||
labels,
|
||||
)
|
||||
deletingEphemeralRunners = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Subsystem: githubScaleSetControllerSubsystem,
|
||||
Name: "deleting_ephemeral_runners",
|
||||
Help: "Number of ephemeral runners in a deleting state.",
|
||||
},
|
||||
labels,
|
||||
)
|
||||
runningListeners = prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Subsystem: githubScaleSetControllerSubsystem,
|
||||
@@ -72,15 +96,31 @@ func RegisterMetrics() {
|
||||
metrics.Registry.MustRegister(
|
||||
pendingEphemeralRunners,
|
||||
runningEphemeralRunners,
|
||||
succeededEphemeralRunners,
|
||||
failedEphemeralRunners,
|
||||
outdatedEphemeralRunners,
|
||||
deletingEphemeralRunners,
|
||||
runningListeners,
|
||||
)
|
||||
}
|
||||
|
||||
func SetEphemeralRunnerCountsByStatus(commonLabels CommonLabels, pending, running, failed int) {
|
||||
pendingEphemeralRunners.With(commonLabels.labels()).Set(float64(pending))
|
||||
runningEphemeralRunners.With(commonLabels.labels()).Set(float64(running))
|
||||
failedEphemeralRunners.With(commonLabels.labels()).Set(float64(failed))
|
||||
// SetEphemeralRunnerCountsByLifecycle sets ephemeral runner counts across all six lifecycle states
|
||||
// using deterministic bucket assignment with explicit precedence:
|
||||
//
|
||||
// Lifecycle Precedence Contract:
|
||||
// 1. deleting: if runner has DeletionTimestamp set
|
||||
// 2. explicit phase buckets: if runner.Status.Phase is one of (Running/Succeeded/Failed/Outdated)
|
||||
// 3. pending (fallback): for empty/unset/other phase values
|
||||
//
|
||||
// This ensures each runner maps to exactly one metric bucket (no double-counting).
|
||||
func SetEphemeralRunnerCountsByLifecycle(commonLabels CommonLabels, pending, running, succeeded, failed, outdated, deleting int) {
|
||||
labels := commonLabels.labels()
|
||||
pendingEphemeralRunners.With(labels).Set(float64(pending))
|
||||
runningEphemeralRunners.With(labels).Set(float64(running))
|
||||
succeededEphemeralRunners.With(labels).Set(float64(succeeded))
|
||||
failedEphemeralRunners.With(labels).Set(float64(failed))
|
||||
outdatedEphemeralRunners.With(labels).Set(float64(outdated))
|
||||
deletingEphemeralRunners.With(labels).Set(float64(deleting))
|
||||
}
|
||||
|
||||
func AddRunningListener(commonLabels CommonLabels) {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSetEphemeralRunnerCountsByLifecycle(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
labels CommonLabels
|
||||
pending int
|
||||
running int
|
||||
succeeded int
|
||||
failed int
|
||||
outdated int
|
||||
deleting int
|
||||
}{
|
||||
{
|
||||
name: "all zeros",
|
||||
labels: CommonLabels{
|
||||
Name: "test-runner",
|
||||
Namespace: "default",
|
||||
Repository: "org/repo",
|
||||
Organization: "org",
|
||||
Enterprise: "enterprise1",
|
||||
},
|
||||
pending: 0,
|
||||
running: 0,
|
||||
succeeded: 0,
|
||||
failed: 0,
|
||||
outdated: 0,
|
||||
deleting: 0,
|
||||
},
|
||||
{
|
||||
name: "mixed counts",
|
||||
labels: CommonLabels{
|
||||
Name: "test-runner",
|
||||
Namespace: "default",
|
||||
Repository: "org/repo",
|
||||
Organization: "org",
|
||||
Enterprise: "enterprise1",
|
||||
},
|
||||
pending: 5,
|
||||
running: 10,
|
||||
succeeded: 3,
|
||||
failed: 2,
|
||||
outdated: 1,
|
||||
deleting: 1,
|
||||
},
|
||||
{
|
||||
name: "all non-zero",
|
||||
labels: CommonLabels{
|
||||
Name: "test-runner-2",
|
||||
Namespace: "kube-system",
|
||||
Repository: "other/repo",
|
||||
Organization: "otherorg",
|
||||
Enterprise: "enterprise2",
|
||||
},
|
||||
pending: 1,
|
||||
running: 1,
|
||||
succeeded: 1,
|
||||
failed: 1,
|
||||
outdated: 1,
|
||||
deleting: 1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
SetEphemeralRunnerCountsByLifecycle(
|
||||
tc.labels,
|
||||
tc.pending,
|
||||
tc.running,
|
||||
tc.succeeded,
|
||||
tc.failed,
|
||||
tc.outdated,
|
||||
tc.deleting,
|
||||
)
|
||||
|
||||
labelString := testutil.CollectAndCount(pendingEphemeralRunners)
|
||||
require.Greater(t, labelString, 0, "metrics should be registered")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetEphemeralRunnerCountsByLifecycleValues(t *testing.T) {
|
||||
lbls := CommonLabels{
|
||||
Name: "precedence-test",
|
||||
Namespace: "default",
|
||||
Repository: "test/repo",
|
||||
Organization: "test",
|
||||
Enterprise: "testent",
|
||||
}
|
||||
|
||||
SetEphemeralRunnerCountsByLifecycle(lbls, 1, 2, 3, 4, 5, 6)
|
||||
|
||||
assertGaugeValue(t, pendingEphemeralRunners, lbls, 1)
|
||||
assertGaugeValue(t, runningEphemeralRunners, lbls, 2)
|
||||
assertGaugeValue(t, succeededEphemeralRunners, lbls, 3)
|
||||
assertGaugeValue(t, failedEphemeralRunners, lbls, 4)
|
||||
assertGaugeValue(t, outdatedEphemeralRunners, lbls, 5)
|
||||
assertGaugeValue(t, deletingEphemeralRunners, lbls, 6)
|
||||
}
|
||||
|
||||
func assertGaugeValue(t *testing.T, gauge *prometheus.GaugeVec, labels CommonLabels, expected float64) {
|
||||
t.Helper()
|
||||
assert.Equal(t, expected, testutil.ToFloat64(gauge.With(labels.labels())))
|
||||
}
|
||||
|
||||
func TestCommonLabelsContract(t *testing.T) {
|
||||
lbls := CommonLabels{
|
||||
Name: "test",
|
||||
Namespace: "ns",
|
||||
Repository: "repo",
|
||||
Organization: "org",
|
||||
Enterprise: "ent",
|
||||
}
|
||||
|
||||
promLabels := lbls.labels()
|
||||
|
||||
expected := prometheus.Labels{
|
||||
"name": "test",
|
||||
"namespace": "ns",
|
||||
"repository": "repo",
|
||||
"organization": "org",
|
||||
"enterprise": "ent",
|
||||
}
|
||||
|
||||
assert.Equal(t, expected, promLabels)
|
||||
}
|
||||
Reference in New Issue
Block a user