Skip to content

Commit 9e5e9ec

Browse files
committed
Support GitHub Apps in controller
1 parent 1a88dce commit 9e5e9ec

File tree

3 files changed

+217
-29
lines changed

3 files changed

+217
-29
lines changed

internal/controllers/runner_controller.go

Lines changed: 184 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
package controllers
22

33
import (
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 (
3138
const (
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

3644
type 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

5162
func (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

139215
func (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+
437605
func (r *RunnerReconciler) cleanupOwnedResources(ctx context.Context, runner *garV1.Runner) error {
438606
var configMaps v1.ConfigMapList
439607
if err := r.List(

main.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ func main() {
4242
var pullRegistryHost string
4343
var enableRunnerMetrics bool
4444
var exporterImage string
45+
var githubAppClientId string
46+
var githubAppInstallationId string
47+
var githubAppPrivateKey string
4548
var kanikoImage string
4649
var binaryVersion string
4750
var runnerVersion string
@@ -56,8 +59,11 @@ func main() {
5659
flag.StringVar(&pullRegistryHost, "pull-registry-host", "ghcr.io/kaidotdev/github-actions-runner-controller", "Host of Docker Registry used as pull source.")
5760
flag.BoolVar(&enableRunnerMetrics, "enable-runner-metrics", false, "Enable to expose runner metrics using prometheus exporter.")
5861
flag.StringVar(&exporterImage, "exporter-image", "ghcr.io/kaidotdev/github-actions-exporter/github-actions-exporter:v0.1.1", "Docker Image of exporter used by exporter container")
62+
flag.StringVar(&githubAppClientId, "github-app-client-id", "", "GitHub App Client ID")
63+
flag.StringVar(&githubAppInstallationId, "github-app-installation-id", "", "GitHub App Installation ID")
64+
flag.StringVar(&githubAppPrivateKey, "github-app-private-key", "", "GitHub App Private Key")
5965
flag.StringVar(&kanikoImage, "kaniko-image", "gcr.io/kaniko-project/executor:v1.23.0", "Docker Image of kaniko used by builder container")
60-
flag.StringVar(&binaryVersion, "binary-version", "0.4.2", "Version of own runner binary")
66+
flag.StringVar(&binaryVersion, "binary-version", "0.4.3", "Version of own runner binary")
6167
flag.StringVar(&runnerVersion, "runner-version", "2.321.0", "Version of GitHub Actions runner")
6268
flag.BoolVar(&disableupdate, "disableupdate", false, "Disable self-hosted runner automatic update to the latest released version")
6369
opts := zap.Options{}
@@ -108,18 +114,20 @@ func main() {
108114
}
109115

110116
if err := (&controllers.RunnerReconciler{
111-
Client: m.GetClient(),
112-
Scheme: m.GetScheme(),
113-
Log: ctrl.Log.WithName("controllers").WithName("Runner"),
114-
Recorder: m.GetEventRecorderFor("github-actions-runner-controller"),
115-
PushRegistryHost: pushRegistryHost,
116-
PullRegistryHost: pullRegistryHost,
117-
EnableRunnerMetrics: enableRunnerMetrics,
118-
ExporterImage: exporterImage,
119-
KanikoImage: kanikoImage,
120-
BinaryVersion: binaryVersion,
121-
RunnerVersion: runnerVersion,
122-
Disableupdate: disableupdate,
117+
Client: m.GetClient(),
118+
Scheme: m.GetScheme(),
119+
Log: ctrl.Log.WithName("controllers").WithName("Runner"),
120+
Recorder: m.GetEventRecorderFor("github-actions-runner-controller"),
121+
PushRegistryHost: pushRegistryHost,
122+
PullRegistryHost: pullRegistryHost,
123+
EnableRunnerMetrics: enableRunnerMetrics,
124+
ExporterImage: exporterImage,
125+
GitHubAppClientId: githubAppClientId,
126+
GitHubAppInstallationId: githubAppInstallationId,
127+
GitHubAppPrivateKey: githubAppPrivateKey, KanikoImage: kanikoImage,
128+
BinaryVersion: binaryVersion,
129+
RunnerVersion: runnerVersion,
130+
Disableupdate: disableupdate,
123131
}).SetupWithManager(m); err != nil {
124132
entrypointLogger.Error(err, "unable to create controller", "controller", "Runner")
125133
os.Exit(1)

manifests/cluster_role.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,18 @@ kind: ClusterRole
33
metadata:
44
name: github-actions-runner-controller
55
rules:
6+
- apiGroups:
7+
- ""
8+
resources:
9+
- secrets
10+
verbs:
11+
- create
12+
- delete
13+
- get
14+
- list
15+
- patch
16+
- update
17+
- watch
618
- apiGroups:
719
- ""
820
resources:

0 commit comments

Comments
 (0)