2021-02-07 17:37:27 +09:00
/ *
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 controllers
import (
"context"
"fmt"
"io/ioutil"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"net/http"
"sigs.k8s.io/controller-runtime/pkg/reconcile"
2021-02-23 08:05:25 +09:00
"strings"
2021-02-07 17:37:27 +09:00
"time"
"github.com/go-logr/logr"
gogithub "github.com/google/go-github/v33/github"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"github.com/summerwind/actions-runner-controller/api/v1alpha1"
)
const (
scaleTargetKey = "scaleTarget"
)
// HorizontalRunnerAutoscalerGitHubWebhook autoscales a HorizontalRunnerAutoscaler and the RunnerDeployment on each
// GitHub Webhook received
type HorizontalRunnerAutoscalerGitHubWebhook struct {
client . Client
Log logr . Logger
Recorder record . EventRecorder
Scheme * runtime . Scheme
// SecretKeyBytes is the byte representation of the Webhook secret token
// the administrator is generated and specified in GitHub Web UI.
SecretKeyBytes [ ] byte
2021-03-09 09:12:46 +09:00
// Namespace is the namespace to watch for HorizontalRunnerAutoscaler's to be
2021-02-07 17:37:27 +09:00
// scaled on Webhook.
// Set to empty for letting it watch for all namespaces.
2021-03-09 09:12:46 +09:00
Namespace string
Name string
2021-02-07 17:37:27 +09:00
}
func ( autoscaler * HorizontalRunnerAutoscalerGitHubWebhook ) Reconcile ( request reconcile . Request ) ( reconcile . Result , error ) {
return ctrl . Result { } , nil
}
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=horizontalrunnerautoscalers,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=horizontalrunnerautoscalers/finalizers,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=horizontalrunnerautoscalers/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
func ( autoscaler * HorizontalRunnerAutoscalerGitHubWebhook ) Handle ( w http . ResponseWriter , r * http . Request ) {
var (
ok bool
err error
)
defer func ( ) {
if ! ok {
w . WriteHeader ( http . StatusInternalServerError )
if err != nil {
msg := err . Error ( )
if written , err := w . Write ( [ ] byte ( msg ) ) ; err != nil {
autoscaler . Log . Error ( err , "failed writing http error response" , "msg" , msg , "written" , written )
}
}
}
} ( )
defer func ( ) {
if r . Body != nil {
r . Body . Close ( )
}
} ( )
2021-03-08 23:46:27 +00:00
// respond ok to GET / e.g. for health check
if r . Method == http . MethodGet {
fmt . Fprintln ( w , "webhook server is running" )
return
}
2021-02-07 17:37:27 +09:00
var payload [ ] byte
if len ( autoscaler . SecretKeyBytes ) > 0 {
payload , err = gogithub . ValidatePayload ( r , autoscaler . SecretKeyBytes )
if err != nil {
autoscaler . Log . Error ( err , "error validating request body" )
return
}
} else {
payload , err = ioutil . ReadAll ( r . Body )
if err != nil {
autoscaler . Log . Error ( err , "error reading request body" )
return
}
}
webhookType := gogithub . WebHookType ( r )
event , err := gogithub . ParseWebHook ( webhookType , payload )
if err != nil {
var s string
if payload != nil {
s = string ( payload )
}
autoscaler . Log . Error ( err , "could not parse webhook" , "webhookType" , webhookType , "payload" , s )
return
}
var target * ScaleTarget
2021-02-25 10:07:41 +09:00
log := autoscaler . Log . WithValues (
"event" , webhookType ,
"hookID" , r . Header . Get ( "X-GitHub-Hook-ID" ) ,
"delivery" , r . Header . Get ( "X-GitHub-Delivery" ) ,
)
2021-02-07 17:37:27 +09:00
switch e := event . ( type ) {
case * gogithub . PushEvent :
target , err = autoscaler . getScaleUpTarget (
context . TODO ( ) ,
2021-02-25 10:07:41 +09:00
log ,
2021-02-23 08:05:25 +09:00
e . Repo . GetName ( ) ,
e . Repo . Owner . GetLogin ( ) ,
e . Repo . Owner . GetType ( ) ,
2021-02-07 17:37:27 +09:00
autoscaler . MatchPushEvent ( e ) ,
)
case * gogithub . PullRequestEvent :
target , err = autoscaler . getScaleUpTarget (
context . TODO ( ) ,
2021-02-25 10:07:41 +09:00
log ,
2021-02-23 08:05:25 +09:00
e . Repo . GetName ( ) ,
e . Repo . Owner . GetLogin ( ) ,
e . Repo . Owner . GetType ( ) ,
2021-02-07 17:37:27 +09:00
autoscaler . MatchPullRequestEvent ( e ) ,
)
2021-03-10 09:40:44 +09:00
if pullRequest := e . PullRequest ; pullRequest != nil {
log = log . WithValues (
"pullRequest.base.ref" , e . PullRequest . Base . GetRef ( ) ,
"action" , e . GetAction ( ) ,
)
}
2021-02-07 17:37:27 +09:00
case * gogithub . CheckRunEvent :
target , err = autoscaler . getScaleUpTarget (
context . TODO ( ) ,
2021-02-25 10:07:41 +09:00
log ,
2021-02-23 08:05:25 +09:00
e . Repo . GetName ( ) ,
e . Repo . Owner . GetLogin ( ) ,
e . Repo . Owner . GetType ( ) ,
2021-02-07 17:37:27 +09:00
autoscaler . MatchCheckRunEvent ( e ) ,
)
2021-03-10 09:40:44 +09:00
if checkRun := e . GetCheckRun ( ) ; checkRun != nil {
log = log . WithValues (
"checkRun.status" , checkRun . GetStatus ( ) ,
"action" , e . GetAction ( ) ,
)
}
2021-02-07 17:37:27 +09:00
case * gogithub . PingEvent :
ok = true
w . WriteHeader ( http . StatusOK )
msg := "pong"
if written , err := w . Write ( [ ] byte ( msg ) ) ; err != nil {
2021-02-25 10:07:41 +09:00
log . Error ( err , "failed writing http response" , "msg" , msg , "written" , written )
2021-02-07 17:37:27 +09:00
}
2021-02-25 10:07:41 +09:00
log . Info ( "received ping event" )
2021-02-07 17:37:27 +09:00
return
default :
2021-02-25 10:07:41 +09:00
log . Info ( "unknown event type" , "eventType" , webhookType )
2021-02-07 17:37:27 +09:00
return
}
if err != nil {
2021-02-25 10:07:41 +09:00
log . Error ( err , "handling check_run event" )
2021-02-07 17:37:27 +09:00
return
}
if target == nil {
2021-03-10 09:40:44 +09:00
log . Info (
"Scale target not found. If this is unexpected, ensure that there is exactly one repository-wide or organizational runner deployment that matches this webhook event" ,
)
2021-02-07 17:37:27 +09:00
2021-03-10 09:40:44 +09:00
msg := "no horizontalrunnerautoscaler to scale for this github event"
2021-02-07 17:37:27 +09:00
ok = true
w . WriteHeader ( http . StatusOK )
if written , err := w . Write ( [ ] byte ( msg ) ) ; err != nil {
2021-02-25 10:07:41 +09:00
log . Error ( err , "failed writing http response" , "msg" , msg , "written" , written )
2021-02-07 17:37:27 +09:00
}
return
}
if err := autoscaler . tryScaleUp ( context . TODO ( ) , target ) ; err != nil {
2021-02-25 10:07:41 +09:00
log . Error ( err , "could not scale up" )
2021-02-07 17:37:27 +09:00
return
}
ok = true
w . WriteHeader ( http . StatusOK )
msg := fmt . Sprintf ( "scaled %s by 1" , target . Name )
autoscaler . Log . Info ( msg )
if written , err := w . Write ( [ ] byte ( msg ) ) ; err != nil {
2021-02-25 10:07:41 +09:00
log . Error ( err , "failed writing http response" , "msg" , msg , "written" , written )
2021-02-07 17:37:27 +09:00
}
}
func ( autoscaler * HorizontalRunnerAutoscalerGitHubWebhook ) findHRAsByKey ( ctx context . Context , value string ) ( [ ] v1alpha1 . HorizontalRunnerAutoscaler , error ) {
2021-03-09 09:12:46 +09:00
ns := autoscaler . Namespace
2021-02-07 17:37:27 +09:00
var defaultListOpts [ ] client . ListOption
if ns != "" {
defaultListOpts = append ( defaultListOpts , client . InNamespace ( ns ) )
}
var hras [ ] v1alpha1 . HorizontalRunnerAutoscaler
if value != "" {
opts := append ( [ ] client . ListOption { } , defaultListOpts ... )
opts = append ( opts , client . MatchingFields { scaleTargetKey : value } )
2021-03-09 09:12:46 +09:00
if autoscaler . Namespace != "" {
opts = append ( opts , client . InNamespace ( autoscaler . Namespace ) )
2021-02-23 08:05:25 +09:00
}
2021-02-07 17:37:27 +09:00
var hraList v1alpha1 . HorizontalRunnerAutoscalerList
if err := autoscaler . List ( ctx , & hraList , opts ... ) ; err != nil {
return nil , err
}
for _ , d := range hraList . Items {
hras = append ( hras , d )
}
}
return hras , nil
}
func matchTriggerConditionAgainstEvent ( types [ ] string , eventAction * string ) bool {
if len ( types ) == 0 {
return true
}
if eventAction == nil {
return false
}
for _ , tpe := range types {
if tpe == * eventAction {
return true
}
}
return false
}
type ScaleTarget struct {
v1alpha1 . HorizontalRunnerAutoscaler
v1alpha1 . ScaleUpTrigger
}
func ( autoscaler * HorizontalRunnerAutoscalerGitHubWebhook ) searchScaleTargets ( hras [ ] v1alpha1 . HorizontalRunnerAutoscaler , f func ( v1alpha1 . ScaleUpTrigger ) bool ) [ ] ScaleTarget {
var matched [ ] ScaleTarget
for _ , hra := range hras {
if ! hra . ObjectMeta . DeletionTimestamp . IsZero ( ) {
continue
}
for _ , scaleUpTrigger := range hra . Spec . ScaleUpTriggers {
if ! f ( scaleUpTrigger ) {
continue
}
matched = append ( matched , ScaleTarget {
HorizontalRunnerAutoscaler : hra ,
ScaleUpTrigger : scaleUpTrigger ,
} )
}
}
return matched
}
func ( autoscaler * HorizontalRunnerAutoscalerGitHubWebhook ) getScaleTarget ( ctx context . Context , name string , f func ( v1alpha1 . ScaleUpTrigger ) bool ) ( * ScaleTarget , error ) {
hras , err := autoscaler . findHRAsByKey ( ctx , name )
if err != nil {
return nil , err
}
targets := autoscaler . searchScaleTargets ( hras , f )
2021-02-25 10:07:41 +09:00
n := len ( targets )
if n == 0 {
return nil , nil
}
if n > 1 {
2021-02-23 08:05:25 +09:00
var scaleTargetIDs [ ] string
for _ , t := range targets {
scaleTargetIDs = append ( scaleTargetIDs , t . HorizontalRunnerAutoscaler . Name )
}
autoscaler . Log . Info (
"Found too many scale targets: " +
"It must be exactly one to avoid ambiguity. " +
2021-03-09 09:12:46 +09:00
"Either set Namespace for the webhook-based autoscaler to let it only find HRAs in the namespace, " +
2021-02-23 08:05:25 +09:00
"or update Repository or Organization fields in your RunnerDeployment resources to fix the ambiguity." ,
"scaleTargets" , strings . Join ( scaleTargetIDs , "," ) )
2021-02-07 17:37:27 +09:00
return nil , nil
}
return & targets [ 0 ] , nil
}
2021-02-25 10:07:41 +09:00
func ( autoscaler * HorizontalRunnerAutoscalerGitHubWebhook ) getScaleUpTarget ( ctx context . Context , log logr . Logger , repo , owner , ownerType string , f func ( v1alpha1 . ScaleUpTrigger ) bool ) ( * ScaleTarget , error ) {
2021-02-23 08:05:25 +09:00
repositoryRunnerKey := owner + "/" + repo
2021-02-20 21:25:23 +09:00
if target , err := autoscaler . getScaleTarget ( ctx , repositoryRunnerKey , f ) ; err != nil {
2021-02-25 10:07:41 +09:00
autoscaler . Log . Info ( "finding repository-wide runner" , "repository" , repositoryRunnerKey )
2021-02-07 17:37:27 +09:00
return nil , err
} else if target != nil {
2021-02-23 08:05:25 +09:00
autoscaler . Log . Info ( "scale up target is repository-wide runners" , "repository" , repo )
2021-02-07 17:37:27 +09:00
return target , nil
}
2021-02-23 08:05:25 +09:00
if ownerType == "User" {
return nil , nil
}
if target , err := autoscaler . getScaleTarget ( ctx , owner , f ) ; err != nil {
2021-02-25 10:07:41 +09:00
log . Info ( "finding organizational runner" , "organization" , owner )
2021-02-07 17:37:27 +09:00
return nil , err
} else if target != nil {
2021-02-25 10:07:41 +09:00
log . Info ( "scale up target is organizational runners" , "organization" , owner )
2021-02-07 17:37:27 +09:00
return target , nil
}
return nil , nil
}
func ( autoscaler * HorizontalRunnerAutoscalerGitHubWebhook ) tryScaleUp ( ctx context . Context , target * ScaleTarget ) error {
if target == nil {
return nil
}
copy := target . HorizontalRunnerAutoscaler . DeepCopy ( )
amount := 1
if target . ScaleUpTrigger . Amount > 0 {
amount = target . ScaleUpTrigger . Amount
}
2021-02-25 11:08:00 +09:00
capacityReservations := getValidCapacityReservations ( copy )
copy . Spec . CapacityReservations = append ( capacityReservations , v1alpha1 . CapacityReservation {
2021-02-07 17:37:27 +09:00
ExpirationTime : metav1 . Time { Time : time . Now ( ) . Add ( target . ScaleUpTrigger . Duration . Duration ) } ,
Replicas : amount ,
} )
2021-02-26 10:17:09 +09:00
if err := autoscaler . Client . Patch ( ctx , copy , client . MergeFrom ( & target . HorizontalRunnerAutoscaler ) ) ; err != nil {
return fmt . Errorf ( "patching horizontalrunnerautoscaler to add capacity reservation: %w" , err )
2021-02-07 17:37:27 +09:00
}
return nil
}
2021-02-25 11:08:00 +09:00
func getValidCapacityReservations ( autoscaler * v1alpha1 . HorizontalRunnerAutoscaler ) [ ] v1alpha1 . CapacityReservation {
var capacityReservations [ ] v1alpha1 . CapacityReservation
now := time . Now ( )
for _ , reservation := range autoscaler . Spec . CapacityReservations {
if reservation . ExpirationTime . Time . After ( now ) {
capacityReservations = append ( capacityReservations , reservation )
}
}
return capacityReservations
}
2021-02-07 17:37:27 +09:00
func ( autoscaler * HorizontalRunnerAutoscalerGitHubWebhook ) SetupWithManager ( mgr ctrl . Manager ) error {
2021-02-16 18:51:33 +09:00
name := "webhookbasedautoscaler"
2021-02-19 10:33:04 +09:00
if autoscaler . Name != "" {
name = autoscaler . Name
}
2021-02-16 18:51:33 +09:00
autoscaler . Recorder = mgr . GetEventRecorderFor ( name )
2021-02-07 17:37:27 +09:00
if err := mgr . GetFieldIndexer ( ) . IndexField ( & v1alpha1 . HorizontalRunnerAutoscaler { } , scaleTargetKey , func ( rawObj runtime . Object ) [ ] string {
hra := rawObj . ( * v1alpha1 . HorizontalRunnerAutoscaler )
if hra . Spec . ScaleTargetRef . Name == "" {
return nil
}
var rd v1alpha1 . RunnerDeployment
if err := autoscaler . Client . Get ( context . Background ( ) , types . NamespacedName { Namespace : hra . Namespace , Name : hra . Spec . ScaleTargetRef . Name } , & rd ) ; err != nil {
return nil
}
return [ ] string { rd . Spec . Template . Spec . Repository , rd . Spec . Template . Spec . Organization }
} ) ; err != nil {
return err
}
return ctrl . NewControllerManagedBy ( mgr ) .
For ( & v1alpha1 . HorizontalRunnerAutoscaler { } ) .
2021-02-16 18:51:33 +09:00
Named ( name ) .
2021-02-07 17:37:27 +09:00
Complete ( autoscaler )
}