2020-06-27 17:26:46 +09:00
package controllers
import (
"context"
2020-07-03 09:05:46 +09:00
"errors"
2020-06-27 17:26:46 +09:00
"fmt"
2020-12-14 21:38:01 -08:00
"math"
2020-12-12 15:48:19 -08:00
"strconv"
2020-06-27 17:26:46 +09:00
"strings"
2020-10-07 17:00:44 -07:00
2021-06-22 17:55:06 +09:00
"github.com/actions-runner-controller/actions-runner-controller/api/v1alpha1"
2022-08-15 19:42:00 +05:30
prometheus_metrics "github.com/actions-runner-controller/actions-runner-controller/controllers/metrics"
2022-07-12 09:45:00 +09:00
arcgithub "github.com/actions-runner-controller/actions-runner-controller/github"
2022-08-24 13:08:40 +09:00
"github.com/google/go-github/v47/github"
2022-06-29 20:49:21 +09:00
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
2020-12-12 15:48:19 -08:00
)
const (
defaultScaleUpThreshold = 0.8
defaultScaleDownThreshold = 0.3
defaultScaleUpFactor = 1.3
defaultScaleDownFactor = 0.7
2020-06-27 17:26:46 +09:00
)
2022-07-12 09:45:00 +09:00
func ( r * HorizontalRunnerAutoscalerReconciler ) suggestDesiredReplicas ( ghc * arcgithub . Client , st scaleTarget , hra v1alpha1 . HorizontalRunnerAutoscaler ) ( * int , error ) {
2020-07-19 18:42:06 +09:00
if hra . Spec . MinReplicas == nil {
return nil , fmt . Errorf ( "horizontalrunnerautoscaler %s/%s is missing minReplicas" , hra . Namespace , hra . Name )
} else if hra . Spec . MaxReplicas == nil {
return nil , fmt . Errorf ( "horizontalrunnerautoscaler %s/%s is missing maxReplicas" , hra . Namespace , hra . Name )
2020-06-27 17:26:46 +09:00
}
2020-12-12 15:48:19 -08:00
metrics := hra . Spec . Metrics
2021-05-05 14:27:17 +09:00
numMetrics := len ( metrics )
if numMetrics == 0 {
2022-04-24 13:36:42 +09:00
// We don't default to anything since ARC 0.23.0
// See https://github.com/actions-runner-controller/actions-runner-controller/issues/728
2021-03-19 12:58:02 +09:00
return nil , nil
2021-05-05 14:27:17 +09:00
} else if numMetrics > 2 {
2021-09-26 07:34:55 +02:00
return nil , fmt . Errorf ( "too many autoscaling metrics configured: It must be 0 to 2, but got %d" , numMetrics )
2021-05-05 14:27:17 +09:00
}
primaryMetric := metrics [ 0 ]
primaryMetricType := primaryMetric . Type
var (
suggested * int
err error
)
switch primaryMetricType {
case v1alpha1 . AutoscalingMetricTypeTotalNumberOfQueuedAndInProgressWorkflowRuns :
2022-07-12 09:45:00 +09:00
suggested , err = r . suggestReplicasByQueuedAndInProgressWorkflowRuns ( ghc , st , hra , & primaryMetric )
2021-05-05 14:27:17 +09:00
case v1alpha1 . AutoscalingMetricTypePercentageRunnersBusy :
2022-07-12 09:45:00 +09:00
suggested , err = r . suggestReplicasByPercentageRunnersBusy ( ghc , st , hra , primaryMetric )
2021-05-05 14:27:17 +09:00
default :
2021-09-26 07:34:55 +02:00
return nil , fmt . Errorf ( "validating autoscaling metrics: unsupported metric type %q" , primaryMetric )
2021-05-05 14:27:17 +09:00
}
if err != nil {
return nil , err
}
if suggested != nil && * suggested > 0 {
return suggested , nil
2020-12-12 15:48:19 -08:00
}
2021-05-05 14:27:17 +09:00
if len ( metrics ) == 1 {
// This is never supposed to happen but anyway-
// Fall-back to `minReplicas + capacityReservedThroughWebhook`.
return nil , nil
}
// At this point, we are sure that there are exactly 2 Metrics entries.
fallbackMetric := metrics [ 1 ]
fallbackMetricType := fallbackMetric . Type
if primaryMetricType != v1alpha1 . AutoscalingMetricTypePercentageRunnersBusy ||
fallbackMetricType != v1alpha1 . AutoscalingMetricTypeTotalNumberOfQueuedAndInProgressWorkflowRuns {
return nil , fmt . Errorf (
"invalid HRA Spec: Metrics[0] of %s cannot be combined with Metrics[1] of %s: The only allowed combination is 0=PercentageRunnersBusy and 1=TotalNumberOfQueuedAndInProgressWorkflowRuns" ,
primaryMetricType , fallbackMetricType ,
)
}
2022-07-12 09:45:00 +09:00
return r . suggestReplicasByQueuedAndInProgressWorkflowRuns ( ghc , st , hra , & fallbackMetric )
2020-12-12 15:48:19 -08:00
}
2022-07-12 09:45:00 +09:00
func ( r * HorizontalRunnerAutoscalerReconciler ) suggestReplicasByQueuedAndInProgressWorkflowRuns ( ghc * arcgithub . Client , st scaleTarget , hra v1alpha1 . HorizontalRunnerAutoscaler , metrics * v1alpha1 . MetricSpec ) ( * int , error ) {
2020-12-12 15:48:19 -08:00
var repos [ ] [ ] string
2021-06-23 20:25:03 +09:00
repoID := st . repo
2020-06-27 17:26:46 +09:00
if repoID == "" {
2021-06-23 20:25:03 +09:00
orgName := st . org
2020-07-03 09:05:46 +09:00
if orgName == "" {
return nil , fmt . Errorf ( "asserting runner deployment spec to detect bug: spec.template.organization should not be empty on this code path" )
}
2021-03-09 15:03:47 +09:00
// In case it's an organizational runners deployment without any scaling metrics defined,
// we assume that the desired replicas should always be `minReplicas + capacityReservedThroughWebhook`.
2021-06-22 17:55:06 +09:00
// See https://github.com/actions-runner-controller/actions-runner-controller/issues/377#issuecomment-793372693
2021-05-05 14:27:17 +09:00
if metrics == nil {
2021-03-19 12:58:02 +09:00
return nil , nil
2021-03-09 15:03:47 +09:00
}
2021-05-05 14:27:17 +09:00
if len ( metrics . RepositoryNames ) == 0 {
2020-07-03 09:05:46 +09:00
return nil , errors . New ( "validating autoscaling metrics: spec.autoscaling.metrics[].repositoryNames is required and must have one more more entries for organizational runner deployment" )
}
2021-05-05 14:27:17 +09:00
for _ , repoName := range metrics . RepositoryNames {
2020-07-03 09:05:46 +09:00
repos = append ( repos , [ ] string { orgName , repoName } )
}
} else {
repo := strings . Split ( repoID , "/" )
repos = append ( repos , repo )
2020-06-27 17:26:46 +09:00
}
var total , inProgress , queued , completed , unknown int
2020-10-07 17:00:44 -07:00
type callback func ( )
listWorkflowJobs := func ( user string , repoName string , runID int64 , fallback_cb callback ) {
if runID == 0 {
fallback_cb ( )
return
}
2021-12-24 01:12:36 +01:00
opt := github . ListWorkflowJobsOptions { ListOptions : github . ListOptions { PerPage : 50 } }
var allJobs [ ] * github . WorkflowJob
for {
2022-07-12 09:45:00 +09:00
jobs , resp , err := ghc . Actions . ListWorkflowJobs ( context . TODO ( ) , user , repoName , runID , & opt )
2021-12-24 01:12:36 +01:00
if err != nil {
r . Log . Error ( err , "Error listing workflow jobs" )
return //err
}
allJobs = append ( allJobs , jobs . Jobs ... )
if resp . NextPage == 0 {
break
}
opt . Page = resp . NextPage
}
if len ( allJobs ) == 0 {
2020-10-07 17:00:44 -07:00
fallback_cb ( )
} else {
2022-04-24 14:41:34 +09:00
JOB :
2021-12-24 01:12:36 +01:00
for _ , job := range allJobs {
2022-04-28 00:24:21 +09:00
runnerLabels := make ( map [ string ] struct { } , len ( st . labels ) )
for _ , l := range st . labels {
runnerLabels [ l ] = struct { } { }
2022-04-24 14:41:34 +09:00
}
2022-04-28 00:24:21 +09:00
if len ( job . Labels ) == 0 {
// This shouldn't usually happen
r . Log . Info ( "Detected job with no labels, which is not supported by ARC. Skipping anyway." , "labels" , job . Labels , "run_id" , job . GetRunID ( ) , "job_id" , job . GetID ( ) )
2022-04-24 14:41:34 +09:00
continue JOB
}
2022-04-28 00:24:21 +09:00
for _ , l := range job . Labels {
if l == "self-hosted" {
continue
}
if _ , ok := runnerLabels [ l ] ; ! ok {
2022-04-24 14:41:34 +09:00
continue JOB
}
}
2020-10-07 17:00:44 -07:00
switch job . GetStatus ( ) {
case "completed" :
// We add a case for `completed` so it is not counted in `unknown`.
// And we do not increment the counter for completed because
// that counter only refers to workflows. The reason for
// this is because we do not get a list of jobs for
// completed workflows in order to keep the number of API
// calls to a minimum.
case "in_progress" :
inProgress ++
case "queued" :
queued ++
default :
unknown ++
}
}
}
}
2020-06-27 17:26:46 +09:00
2020-07-03 09:05:46 +09:00
for _ , repo := range repos {
user , repoName := repo [ 0 ] , repo [ 1 ]
2022-07-12 09:45:00 +09:00
workflowRuns , err := ghc . ListRepositoryWorkflowRuns ( context . TODO ( ) , user , repoName )
2020-07-03 09:05:46 +09:00
if err != nil {
return nil , err
}
2021-02-09 10:13:53 +09:00
for _ , run := range workflowRuns {
2020-07-03 09:05:46 +09:00
total ++
// In May 2020, there are only 3 statuses.
// Follow the below links for more details:
// - https://developer.github.com/v3/actions/workflow-runs/#list-repository-workflow-runs
// - https://developer.github.com/v3/checks/runs/#create-a-check-run
2020-10-07 17:00:44 -07:00
switch run . GetStatus ( ) {
2020-07-03 09:05:46 +09:00
case "completed" :
completed ++
case "in_progress" :
2020-10-07 17:00:44 -07:00
listWorkflowJobs ( user , repoName , run . GetID ( ) , func ( ) { inProgress ++ } )
2020-07-03 09:05:46 +09:00
case "queued" :
2020-10-07 17:00:44 -07:00
listWorkflowJobs ( user , repoName , run . GetID ( ) , func ( ) { queued ++ } )
2020-07-03 09:05:46 +09:00
default :
unknown ++
}
2020-06-27 17:26:46 +09:00
}
}
necessaryReplicas := queued + inProgress
2022-08-15 19:42:00 +05:30
prometheus_metrics . SetHorizontalRunnerAutoscalerQueuedAndInProgressWorkflowRuns (
hra . ObjectMeta ,
st . enterprise ,
st . org ,
st . repo ,
st . kind ,
st . st ,
necessaryReplicas ,
completed ,
inProgress ,
queued ,
unknown ,
)
2020-06-27 17:26:46 +09:00
r . Log . V ( 1 ) . Info (
2021-03-19 12:58:02 +09:00
fmt . Sprintf ( "Suggested desired replicas of %d by TotalNumberOfQueuedAndInProgressWorkflowRuns" , necessaryReplicas ) ,
2020-06-27 17:26:46 +09:00
"workflow_runs_completed" , completed ,
"workflow_runs_in_progress" , inProgress ,
"workflow_runs_queued" , queued ,
"workflow_runs_unknown" , unknown ,
2021-02-16 12:44:51 +09:00
"namespace" , hra . Namespace ,
2021-06-23 20:25:03 +09:00
"kind" , st . kind ,
"name" , st . st ,
2021-02-16 12:44:51 +09:00
"horizontal_runner_autoscaler" , hra . Name ,
2020-06-27 17:26:46 +09:00
)
2021-03-19 12:58:02 +09:00
return & necessaryReplicas , nil
2020-06-27 17:26:46 +09:00
}
2020-12-12 15:48:19 -08:00
2022-07-12 09:45:00 +09:00
func ( r * HorizontalRunnerAutoscalerReconciler ) suggestReplicasByPercentageRunnersBusy ( ghc * arcgithub . Client , st scaleTarget , hra v1alpha1 . HorizontalRunnerAutoscaler , metrics v1alpha1 . MetricSpec ) ( * int , error ) {
2020-12-12 15:48:19 -08:00
ctx := context . Background ( )
scaleUpThreshold := defaultScaleUpThreshold
scaleDownThreshold := defaultScaleDownThreshold
scaleUpFactor := defaultScaleUpFactor
scaleDownFactor := defaultScaleDownFactor
if metrics . ScaleUpThreshold != "" {
sut , err := strconv . ParseFloat ( metrics . ScaleUpThreshold , 64 )
if err != nil {
return nil , errors . New ( "validating autoscaling metrics: spec.autoscaling.metrics[].scaleUpThreshold cannot be parsed into a float64" )
}
scaleUpThreshold = sut
}
if metrics . ScaleDownThreshold != "" {
sdt , err := strconv . ParseFloat ( metrics . ScaleDownThreshold , 64 )
if err != nil {
return nil , errors . New ( "validating autoscaling metrics: spec.autoscaling.metrics[].scaleDownThreshold cannot be parsed into a float64" )
}
scaleDownThreshold = sdt
}
2021-02-16 17:16:26 +09:00
scaleUpAdjustment := metrics . ScaleUpAdjustment
if scaleUpAdjustment != 0 {
if metrics . ScaleUpAdjustment < 0 {
return nil , errors . New ( "validating autoscaling metrics: spec.autoscaling.metrics[].scaleUpAdjustment cannot be lower than 0" )
}
if metrics . ScaleUpFactor != "" {
return nil , errors . New ( "validating autoscaling metrics: spec.autoscaling.metrics[]: scaleUpAdjustment and scaleUpFactor cannot be specified together" )
}
} else if metrics . ScaleUpFactor != "" {
2020-12-12 15:48:19 -08:00
suf , err := strconv . ParseFloat ( metrics . ScaleUpFactor , 64 )
if err != nil {
return nil , errors . New ( "validating autoscaling metrics: spec.autoscaling.metrics[].scaleUpFactor cannot be parsed into a float64" )
}
scaleUpFactor = suf
}
2021-02-16 17:16:26 +09:00
scaleDownAdjustment := metrics . ScaleDownAdjustment
if scaleDownAdjustment != 0 {
if metrics . ScaleDownAdjustment < 0 {
return nil , errors . New ( "validating autoscaling metrics: spec.autoscaling.metrics[].scaleDownAdjustment cannot be lower than 0" )
}
if metrics . ScaleDownFactor != "" {
return nil , errors . New ( "validating autoscaling metrics: spec.autoscaling.metrics[]: scaleDownAdjustment and scaleDownFactor cannot be specified together" )
}
} else if metrics . ScaleDownFactor != "" {
2020-12-12 15:48:19 -08:00
sdf , err := strconv . ParseFloat ( metrics . ScaleDownFactor , 64 )
if err != nil {
return nil , errors . New ( "validating autoscaling metrics: spec.autoscaling.metrics[].scaleDownFactor cannot be parsed into a float64" )
}
scaleDownFactor = sdf
}
2021-06-23 20:25:03 +09:00
runnerMap , err := st . getRunnerMap ( )
2021-03-05 10:15:39 +09:00
if err != nil {
return nil , err
}
2021-03-11 20:16:36 +09:00
2021-02-16 12:44:51 +09:00
var (
2021-06-23 20:25:03 +09:00
enterprise = st . enterprise
organization = st . org
repository = st . repo
2021-02-16 12:44:51 +09:00
)
2020-12-12 15:48:19 -08:00
// ListRunners will return all runners managed by GitHub - not restricted to ns
2022-07-12 09:45:00 +09:00
runners , err := ghc . ListRunners (
2021-02-16 12:44:51 +09:00
ctx ,
enterprise ,
organization ,
repository )
2020-12-12 15:48:19 -08:00
if err != nil {
return nil , err
}
2021-03-05 10:21:20 +09:00
var desiredReplicasBefore int
2021-06-23 20:25:03 +09:00
if v := st . replicas ; v == nil {
2021-03-05 10:21:20 +09:00
desiredReplicasBefore = 1
} else {
desiredReplicasBefore = * v
}
var (
numRunners int
numRunnersRegistered int
numRunnersBusy int
2022-06-29 20:49:21 +09:00
numTerminatingBusy int
2021-03-05 10:21:20 +09:00
)
2021-06-23 20:25:03 +09:00
numRunners = len ( runnerMap )
2021-03-05 10:21:20 +09:00
2022-06-29 20:49:21 +09:00
busyTerminatingRunnerPods := map [ string ] struct { } { }
kindLabel := LabelKeyRunnerDeploymentName
if hra . Spec . ScaleTargetRef . Kind == "RunnerSet" {
kindLabel = LabelKeyRunnerSetName
}
var runnerPodList corev1 . PodList
if err := r . Client . List ( ctx , & runnerPodList , client . InNamespace ( hra . Namespace ) , client . MatchingLabels ( map [ string ] string {
kindLabel : hra . Spec . ScaleTargetRef . Name ,
} ) ) ; err != nil {
return nil , err
}
for _ , p := range runnerPodList . Items {
if p . Annotations [ AnnotationKeyUnregistrationFailureMessage ] != "" {
busyTerminatingRunnerPods [ p . Name ] = struct { } { }
}
}
2020-12-12 15:48:19 -08:00
for _ , runner := range runners {
2021-03-05 10:21:20 +09:00
if _ , ok := runnerMap [ * runner . Name ] ; ok {
numRunnersRegistered ++
if runner . GetBusy ( ) {
numRunnersBusy ++
2022-06-29 20:49:21 +09:00
} else if _ , ok := busyTerminatingRunnerPods [ * runner . Name ] ; ok {
numTerminatingBusy ++
2021-03-05 10:21:20 +09:00
}
2022-06-29 20:49:21 +09:00
delete ( busyTerminatingRunnerPods , * runner . Name )
2020-12-12 15:48:19 -08:00
}
}
2022-06-29 20:49:21 +09:00
// Remaining busyTerminatingRunnerPods are runners that were not on the ListRunners API response yet
for range busyTerminatingRunnerPods {
numTerminatingBusy ++
}
2020-12-12 15:48:19 -08:00
var desiredReplicas int
2022-06-29 20:49:21 +09:00
fractionBusy := float64 ( numRunnersBusy + numTerminatingBusy ) / float64 ( desiredReplicasBefore )
2020-12-12 15:48:19 -08:00
if fractionBusy >= scaleUpThreshold {
2021-02-16 17:16:26 +09:00
if scaleUpAdjustment > 0 {
2021-03-05 10:21:20 +09:00
desiredReplicas = desiredReplicasBefore + scaleUpAdjustment
2021-02-16 17:16:26 +09:00
} else {
2021-03-05 10:21:20 +09:00
desiredReplicas = int ( math . Ceil ( float64 ( desiredReplicasBefore ) * scaleUpFactor ) )
2021-02-16 17:16:26 +09:00
}
2020-12-12 15:48:19 -08:00
} else if fractionBusy < scaleDownThreshold {
2021-02-16 17:16:26 +09:00
if scaleDownAdjustment > 0 {
2021-03-05 10:21:20 +09:00
desiredReplicas = desiredReplicasBefore - scaleDownAdjustment
2021-02-16 17:16:26 +09:00
} else {
2021-03-05 10:21:20 +09:00
desiredReplicas = int ( float64 ( desiredReplicasBefore ) * scaleDownFactor )
2021-02-16 17:16:26 +09:00
}
2020-12-12 15:48:19 -08:00
} else {
2021-06-23 20:25:03 +09:00
desiredReplicas = * st . replicas
2020-12-12 15:48:19 -08:00
}
2021-01-24 10:58:35 +09:00
2021-03-05 10:21:20 +09:00
// NOTES for operators:
//
// - num_runners can be as twice as large as replicas_desired_before while
// the runnerdeployment controller is replacing RunnerReplicaSet for runner update.
2022-08-15 19:42:00 +05:30
prometheus_metrics . SetHorizontalRunnerAutoscalerPercentageRunnersBusy (
hra . ObjectMeta ,
st . enterprise ,
st . org ,
st . repo ,
st . kind ,
st . st ,
desiredReplicas ,
numRunners ,
numRunnersRegistered ,
numRunnersBusy ,
numTerminatingBusy ,
)
2021-03-05 10:21:20 +09:00
2020-12-12 15:48:19 -08:00
r . Log . V ( 1 ) . Info (
2021-03-19 12:58:02 +09:00
fmt . Sprintf ( "Suggested desired replicas of %d by PercentageRunnersBusy" , desiredReplicas ) ,
2021-03-05 10:21:20 +09:00
"replicas_desired_before" , desiredReplicasBefore ,
"replicas_desired" , desiredReplicas ,
2020-12-12 15:48:19 -08:00
"num_runners" , numRunners ,
2021-03-05 10:21:20 +09:00
"num_runners_registered" , numRunnersRegistered ,
2020-12-12 15:48:19 -08:00
"num_runners_busy" , numRunnersBusy ,
2022-06-29 20:49:21 +09:00
"num_terminating_busy" , numTerminatingBusy ,
2021-02-16 12:44:51 +09:00
"namespace" , hra . Namespace ,
2021-06-23 20:25:03 +09:00
"kind" , st . kind ,
"name" , st . st ,
2021-02-16 12:44:51 +09:00
"horizontal_runner_autoscaler" , hra . Name ,
"enterprise" , enterprise ,
"organization" , organization ,
"repository" , repository ,
2020-12-12 15:48:19 -08:00
)
2021-03-19 12:58:02 +09:00
return & desiredReplicas , nil
2020-12-12 15:48:19 -08:00
}