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"
2021-02-07 17:37:27 +09:00
"time"
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"
2021-12-24 01:12:36 +01:00
"github.com/google/go-github/v39/github"
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
)
2021-02-07 17:37:27 +09:00
func getValueAvailableAt ( now time . Time , from , to * time . Time , reservedValue int ) * int {
if to != nil && now . After ( * to ) {
return nil
}
if from != nil && now . Before ( * from ) {
return nil
}
return & reservedValue
}
2021-03-19 12:58:02 +09:00
func ( r * HorizontalRunnerAutoscalerReconciler ) fetchSuggestedReplicasFromCache ( hra v1alpha1 . HorizontalRunnerAutoscaler ) * int {
2021-02-07 17:37:27 +09:00
var entry * v1alpha1 . CacheEntry
for i := range hra . Status . CacheEntries {
ent := hra . Status . CacheEntries [ i ]
if ent . Key != v1alpha1 . CacheEntryKeyDesiredReplicas {
continue
}
if ! time . Now ( ) . Before ( ent . ExpirationTime . Time ) {
continue
}
entry = & ent
break
}
if entry != nil {
v := getValueAvailableAt ( time . Now ( ) , nil , & entry . ExpirationTime . Time , entry . Value )
if v != nil {
return v
}
}
return nil
}
2021-06-23 20:25:03 +09:00
func ( r * HorizontalRunnerAutoscalerReconciler ) suggestDesiredReplicas ( 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 {
2021-03-14 11:03:00 +09:00
if len ( hra . Spec . ScaleUpTriggers ) == 0 {
2021-06-23 20:25:03 +09:00
return r . suggestReplicasByQueuedAndInProgressWorkflowRuns ( st , hra , nil )
2021-03-14 11:03:00 +09:00
}
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 :
2021-06-23 20:25:03 +09:00
suggested , err = r . suggestReplicasByQueuedAndInProgressWorkflowRuns ( st , hra , & primaryMetric )
2021-05-05 14:27:17 +09:00
case v1alpha1 . AutoscalingMetricTypePercentageRunnersBusy :
2021-06-23 20:25:03 +09:00
suggested , err = r . suggestReplicasByPercentageRunnersBusy ( 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 ,
)
}
2021-06-23 20:25:03 +09:00
return r . suggestReplicasByQueuedAndInProgressWorkflowRuns ( st , hra , & fallbackMetric )
2020-12-12 15:48:19 -08:00
}
2021-06-23 20:25:03 +09:00
func ( r * HorizontalRunnerAutoscalerReconciler ) suggestReplicasByQueuedAndInProgressWorkflowRuns ( st scaleTarget , hra v1alpha1 . HorizontalRunnerAutoscaler , metrics * v1alpha1 . MetricSpec ) ( * int , error ) {
2020-06-27 17:26:46 +09:00
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 {
jobs , resp , err := r . GitHubClient . Actions . ListWorkflowJobs ( context . TODO ( ) , user , repoName , runID , & opt )
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 {
2021-12-24 01:12:36 +01:00
for _ , job := range allJobs {
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 ]
2021-02-09 10:13:53 +09:00
workflowRuns , err := r . GitHubClient . 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
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
2021-06-23 20:25:03 +09:00
func ( r * HorizontalRunnerAutoscalerReconciler ) suggestReplicasByPercentageRunnersBusy ( 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
2021-02-16 12:44:51 +09:00
runners , err := r . GitHubClient . ListRunners (
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
)
2021-06-23 20:25:03 +09:00
numRunners = len ( runnerMap )
2021-03-05 10:21:20 +09:00
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 ++
}
2020-12-12 15:48:19 -08:00
}
}
var desiredReplicas int
2021-03-05 10:21:20 +09:00
fractionBusy := float64 ( numRunnersBusy ) / 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.
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 ,
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
}