11package controllers
22
33import (
4+ "bytes"
45 "context"
56 "crypto/sha256"
7+ "crypto/x509"
8+ "encoding/json"
9+ "encoding/pem"
610 "fmt"
11+ "net/http"
712 "reflect"
813 "strings"
914 "time"
@@ -12,10 +17,12 @@ import (
1217
1318 dockerref "github.com/docker/distribution/reference"
1419 "github.com/go-logr/logr"
20+ "github.com/golang-jwt/jwt/v5"
21+ "golang.org/x/xerrors"
1522 appsV1 "k8s.io/api/apps/v1"
1623 coreV1 "k8s.io/api/core/v1"
1724 v1 "k8s.io/api/core/v1"
18- "k8s.io/apimachinery/pkg/api/errors"
25+ apierrors "k8s.io/apimachinery/pkg/api/errors"
1926 "k8s.io/apimachinery/pkg/api/resource"
2027 metaV1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2128 "k8s.io/apimachinery/pkg/runtime"
@@ -31,28 +38,34 @@ import (
3138const (
3239 ownerKey = ".metadata.controller"
3340 optimisticLockErrorMsg = "the object has been modified; please apply your changes to the latest version and try again"
41+ expiresAtAnnotation = "github-actions-runner.kaidotio.github.io/expiresAt"
3442)
3543
3644type RunnerReconciler struct {
3745 client.Client
38- Log logr.Logger
39- Scheme * runtime.Scheme
40- Recorder record.EventRecorder
41- PushRegistryHost string
42- PullRegistryHost string
43- EnableRunnerMetrics bool
44- ExporterImage string
45- KanikoImage string
46- BinaryVersion string
47- RunnerVersion string
48- Disableupdate bool
46+ Log logr.Logger
47+ Scheme * runtime.Scheme
48+ Recorder record.EventRecorder
49+ PushRegistryHost string
50+ PullRegistryHost string
51+ EnableRunnerMetrics bool
52+ ExporterImage string
53+ GitHubAppClientId string
54+ GitHubAppInstallationId string
55+ GitHubAppPrivateKey string
56+ KanikoImage string
57+ BinaryVersion string
58+ RunnerVersion string
59+ Disableupdate bool
4960}
5061
5162func (r * RunnerReconciler ) Reconcile (ctx context.Context , req ctrl.Request ) (ctrl.Result , error ) {
63+ var requeueAfter time.Duration
64+
5265 runner := & garV1.Runner {}
5366 logger := r .Log .WithValues ("runner" , req .NamespacedName )
5467 if err := r .Get (ctx , req .NamespacedName , runner ); err != nil {
55- if errors .IsNotFound (err ) {
68+ if apierrors .IsNotFound (err ) {
5669 return ctrl.Result {}, nil
5770 }
5871 return ctrl.Result {}, err
@@ -62,6 +75,69 @@ func (r *RunnerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
6275 return ctrl.Result {}, err
6376 }
6477
78+ if r .GitHubAppClientId != "" && r .GitHubAppInstallationId != "" && r .GitHubAppPrivateKey != "" {
79+ var tokenSecret v1.Secret
80+ if err := r .Client .Get (
81+ ctx ,
82+ client.ObjectKey {
83+ Name : req .Name ,
84+ Namespace : req .Namespace ,
85+ },
86+ & tokenSecret ,
87+ ); apierrors .IsNotFound (err ) {
88+ tokenSecret , err := r .createTokenSecret (runner )
89+ if err != nil {
90+ return ctrl.Result {}, err
91+ }
92+ if err := controllerutil .SetControllerReference (runner , tokenSecret , r .Scheme ); err != nil {
93+ return ctrl.Result {}, err
94+ }
95+ if err := r .Create (ctx , tokenSecret ); err != nil {
96+ return ctrl.Result {}, err
97+ }
98+ r .Recorder .Eventf (runner , coreV1 .EventTypeNormal , "SuccessfulCreated" , "Created token secret: %q" , tokenSecret .Name )
99+ logger .V (1 ).Info ("create" , "secret" , tokenSecret )
100+
101+ expire , err := time .Parse (time .RFC3339 , tokenSecret .Annotations [expiresAtAnnotation ])
102+ if err != nil {
103+ return ctrl.Result {}, err
104+ }
105+ requeueAfter = expire .Sub (time .Now ()) - time .Minute
106+ } else if err != nil {
107+ return ctrl.Result {}, err
108+ } else {
109+ expectedTokenSecret , err := r .createTokenSecret (runner )
110+ if err != nil {
111+ return ctrl.Result {}, err
112+ }
113+ if ! reflect .DeepEqual (tokenSecret .Data , expectedTokenSecret .Data ) ||
114+ ! reflect .DeepEqual (tokenSecret .StringData , expectedTokenSecret .StringData ) {
115+ tokenSecret .Annotations = expectedTokenSecret .Annotations
116+ tokenSecret .Data = expectedTokenSecret .Data
117+ tokenSecret .StringData = expectedTokenSecret .StringData
118+
119+ if err := r .Update (ctx , & tokenSecret ); err != nil {
120+ return ctrl.Result {}, err
121+ }
122+ r .Recorder .Eventf (runner , coreV1 .EventTypeNormal , "SuccessfulUpdated" , "Updated token secret: %q" , tokenSecret .Name )
123+ logger .V (1 ).Info ("update" , "secret" , tokenSecret )
124+
125+ expire , err := time .Parse (time .RFC3339 , tokenSecret .Annotations [expiresAtAnnotation ])
126+ if err != nil {
127+ return ctrl.Result {}, err
128+ }
129+ requeueAfter = expire .Sub (time .Now ()) - time .Minute
130+ }
131+ }
132+
133+ runner .Spec .TokenSecretKeyRef = & coreV1.SecretKeySelector {
134+ LocalObjectReference : coreV1.LocalObjectReference {
135+ Name : req .Name ,
136+ },
137+ Key : "GITHUB_TOKEN" ,
138+ }
139+ }
140+
65141 var workspaceConfigMap v1.ConfigMap
66142 if err := r .Client .Get (
67143 ctx ,
@@ -70,7 +146,7 @@ func (r *RunnerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
70146 Namespace : req .Namespace ,
71147 },
72148 & workspaceConfigMap ,
73- ); errors .IsNotFound (err ) {
149+ ); apierrors .IsNotFound (err ) {
74150 workspaceConfigMap = * r .buildWorkspaceConfigMap (runner )
75151 if err := controllerutil .SetControllerReference (runner , & workspaceConfigMap , r .Scheme ); err != nil {
76152 return ctrl.Result {}, err
@@ -105,7 +181,7 @@ func (r *RunnerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
105181 Namespace : req .Namespace ,
106182 },
107183 & deployment ,
108- ); errors .IsNotFound (err ) {
184+ ); apierrors .IsNotFound (err ) {
109185 deployment = * r .buildDeployment (runner )
110186 if err := controllerutil .SetControllerReference (runner , & deployment , r .Scheme ); err != nil {
111187 return ctrl.Result {}, err
@@ -133,7 +209,7 @@ func (r *RunnerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctr
133209 }
134210 }
135211
136- return ctrl.Result {}, nil
212+ return ctrl.Result {RequeueAfter : requeueAfter }, nil
137213}
138214
139215func (r * RunnerReconciler ) buildRepositoryName (runner * garV1.Runner ) string {
@@ -434,6 +510,98 @@ ENTRYPOINT ["/usr/local/bin/runner"]
434510 }
435511}
436512
513+ func (r * RunnerReconciler ) createTokenSecret (runner * garV1.Runner ) (* v1.Secret , error ) {
514+ body := struct {
515+ Repositories []string `json:"repositories"`
516+ RepositoryIds []int `json:"repository_ids"`
517+ Permissions map [string ]string `json:"permissions"`
518+ }{}
519+
520+ accessToken := struct {
521+ Token string `json:"token"`
522+ ExpiresAt string `json:"expires_at"`
523+ }{}
524+
525+ err , jwtToken := signJwt (r .GitHubAppPrivateKey , r .GitHubAppClientId )
526+ if err != nil {
527+ return nil , xerrors .Errorf ("failed to sign jwt: %w" , err )
528+ }
529+
530+ body .Repositories = []string {strings .SplitN (runner .Spec .Repository , "/" , 2 )[0 ]}
531+ body .Permissions = map [string ]string {
532+ "actions" : "read" ,
533+ "administration" : "write" ,
534+ "metadata" : "read" ,
535+ }
536+ b , err := json .Marshal (body )
537+ if err != nil {
538+ return nil , xerrors .Errorf ("failed to marshal body: %w" , err )
539+ }
540+
541+ accessTokenRequest , err := http .NewRequest ("POST" , fmt .Sprintf ("https://api.github.com/app/installations/%s/access_tokens" , r .GitHubAppInstallationId ), bytes .NewReader (b ))
542+ if err != nil {
543+ return nil , xerrors .Errorf ("failed to create request: %w" , err )
544+ }
545+
546+ accessTokenRequest .Header .Set ("Accept" , "application/vnd.github+json" )
547+ accessTokenRequest .Header .Set ("Authorization" , fmt .Sprintf ("Bearer %s" , * jwtToken ))
548+ accessTokenRequest .Header .Set ("X-GitHub-Api-Version" , "2022-11-28" )
549+ accessTokenResponse , err := http .DefaultClient .Do (accessTokenRequest )
550+ if err != nil {
551+ return nil , xerrors .Errorf ("failed to do request: %w" , err )
552+ }
553+ defer func () {
554+ _ = accessTokenResponse .Body .Close ()
555+ }()
556+
557+ if accessTokenResponse .StatusCode != http .StatusCreated {
558+ return nil , xerrors .Errorf ("failed to get access token: %d" , accessTokenResponse .StatusCode )
559+ }
560+
561+ if err := json .NewDecoder (accessTokenResponse .Body ).Decode (& accessToken ); err != nil {
562+ return nil , xerrors .Errorf ("failed to decode access token: %w" , err )
563+ }
564+
565+ return & v1.Secret {
566+ ObjectMeta : metaV1.ObjectMeta {
567+ Name : runner .Name ,
568+ Namespace : runner .Namespace ,
569+ Annotations : map [string ]string {
570+ expiresAtAnnotation : accessToken .ExpiresAt ,
571+ },
572+ },
573+ StringData : map [string ]string {
574+ "GITHUB_TOKEN" : accessToken .Token ,
575+ },
576+ }, nil
577+ }
578+
579+ func signJwt (privateKey string , clientId string ) (error , * string ) {
580+ block , _ := pem .Decode ([]byte (privateKey ))
581+ if block == nil {
582+ return xerrors .New ("failed to decode private key" ), nil
583+ }
584+
585+ rsaPrivateKey , err := x509 .ParsePKCS1PrivateKey (block .Bytes )
586+ if err != nil {
587+ return xerrors .Errorf ("failed to parse private key: %w" , err ), nil
588+ }
589+
590+ now := time .Now ()
591+ claims := jwt.MapClaims {
592+ "iat" : now .Unix (),
593+ "exp" : now .Add (time .Minute * 10 ).Unix (),
594+ "iss" : clientId ,
595+ }
596+
597+ token := jwt .NewWithClaims (jwt .SigningMethodRS256 , claims )
598+ jwtToken , err := token .SignedString (rsaPrivateKey )
599+ if err != nil {
600+ return xerrors .Errorf ("failed to sign token: %w" , err ), nil
601+ }
602+ return nil , & jwtToken
603+ }
604+
437605func (r * RunnerReconciler ) cleanupOwnedResources (ctx context.Context , runner * garV1.Runner ) error {
438606 var configMaps v1.ConfigMapList
439607 if err := r .List (
0 commit comments