Skip to content

Commit dfffd3f

Browse files
authored
feat: EKS IAM Roles for Service Accounts for Runner Pods (actions#226)
One of the pod recreation conditions has been modified to use hash of runner spec, so that the controller does not keep restarting pods mutated by admission webhooks. This naturally allows us, for example, to use IRSA for EKS that requires its admission webhook to mutate the runner pod to have additional, IRSA-related volumes, volume mounts and env. Resolves actions#200
1 parent f710a54 commit dfffd3f

File tree

10 files changed

+197
-23
lines changed

10 files changed

+197
-23
lines changed

Makefile

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,20 +126,22 @@ release/clean:
126126
rm -rf release
127127

128128
.PHONY: acceptance
129-
acceptance: release/clean release
130-
ACCEPTANCE_TEST_SECRET_TYPE=token make acceptance/setup acceptance/tests acceptance/teardown
131-
ACCEPTANCE_TEST_SECRET_TYPE=app make acceptance/setup acceptance/tests acceptance/teardown
132-
ACCEPTANCE_TEST_DEPLOYMENT_TOOL=helm ACCEPTANCE_TEST_SECRET_TYPE=token make acceptance/setup acceptance/tests acceptance/teardown
133-
ACCEPTANCE_TEST_DEPLOYMENT_TOOL=helm ACCEPTANCE_TEST_SECRET_TYPE=app make acceptance/setup acceptance/tests acceptance/teardown
129+
acceptance: release/clean docker-build docker-push release
130+
ACCEPTANCE_TEST_SECRET_TYPE=token make acceptance/kind acceptance/setup acceptance/tests acceptance/teardown
131+
ACCEPTANCE_TEST_SECRET_TYPE=app make acceptance/kind acceptance/setup acceptance/tests acceptance/teardown
132+
ACCEPTANCE_TEST_DEPLOYMENT_TOOL=helm ACCEPTANCE_TEST_SECRET_TYPE=token make acceptance/kind acceptance/setup acceptance/tests acceptance/teardown
133+
ACCEPTANCE_TEST_DEPLOYMENT_TOOL=helm ACCEPTANCE_TEST_SECRET_TYPE=app make acceptance/kind acceptance/setup acceptance/tests acceptance/teardown
134134

135-
acceptance/setup:
135+
acceptance/kind:
136136
kind create cluster --name acceptance
137137
kubectl cluster-info --context kind-acceptance
138+
139+
acceptance/setup:
138140
kubectl apply --validate=false -f https://github.com/jetstack/cert-manager/releases/download/v1.0.4/cert-manager.yaml #kubectl create namespace actions-runner-system
139141
kubectl -n cert-manager wait deploy/cert-manager-cainjector --for condition=available --timeout 60s
140142
kubectl -n cert-manager wait deploy/cert-manager-webhook --for condition=available --timeout 60s
141143
kubectl -n cert-manager wait deploy/cert-manager --for condition=available --timeout 60s
142-
kubectl create namespace actions-runner-system
144+
kubectl create namespace actions-runner-system || true
143145
# Adhocly wait for some time until cert-manager's admission webhook gets ready
144146
sleep 5
145147

README.md

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,32 @@ spec:
404404
group: NewGroup
405405
```
406406

407+
## Using EKS IAM role for service accounts
408+
409+
`actions-runner-controller` v0.15.0 or later has support for EKS IAM role for service accounts.
410+
411+
As similar as for regular pods and deployments, you firstly need an existing service account with the IAM role associated.
412+
Create one using e.g. `eksctl`. You can refer to [the EKS documentation](https://docs.aws.amazon.com/eks/latest/userguide/iam-roles-for-service-accounts.html) for more details.
413+
414+
Once you set up the service account, all you need is to add `serviceAccountName` and `fsGroup` to any pods that uses
415+
the IAM-role enabled service account.
416+
417+
For `RunnerDeployment`, you can set those two fields under the runner spec at `RunnerDeployment.Spec.Template`:
418+
419+
```yaml
420+
apiVersion: actions.summerwind.dev/v1alpha1
421+
kind: RunnerDeployment
422+
metadata:
423+
name: example-runnerdeploy
424+
spec:
425+
template:
426+
spec:
427+
repository: USER/REO
428+
serviceAccountName: my-service-account
429+
securityContext:
430+
fsGroup: 1447
431+
```
432+
407433
## Software installed in the runner image
408434

409435
The GitHub hosted runners include a large amount of pre-installed software packages. For Ubuntu 18.04, this list can be found at <https://github.com/actions/virtual-environments/blob/master/images/linux/Ubuntu1804-README.md>
@@ -458,7 +484,10 @@ If you'd like to modify the controller to fork or contribute, I'd suggest using
458484
the acceptance test:
459485

460486
```shell
461-
NAME=$DOCKER_USER/actions-runner-controller VERSION=dev \
487+
# This sets `VERSION` envvar to some appropriate value
488+
. hack/make-env.sh
489+
490+
NAME=$DOCKER_USER/actions-runner-controller \
462491
GITHUB_TOKEN=*** \
463492
APP_ID=*** \
464493
PRIVATE_KEY_FILE_PATH=path/to/pem/file \
@@ -474,6 +503,19 @@ The test creates a one-off `kind` cluster, deploys `cert-manager` and `actions-r
474503
creates a `RunnerDeployment` custom resource for a public Git repository to confirm that the
475504
controller is able to bring up a runner pod with the actions runner registration token installed.
476505

506+
If you prefer to test in a non-kind cluster, you can instead run:
507+
508+
```shell script
509+
KUBECONFIG=path/to/kubeconfig \
510+
NAME=$DOCKER_USER/actions-runner-controller \
511+
GITHUB_TOKEN=*** \
512+
APP_ID=*** \
513+
PRIVATE_KEY_FILE_PATH=path/to/pem/file \
514+
INSTALLATION_ID=*** \
515+
ACCEPTANCE_TEST_SECRET_TYPE=token \
516+
make docker-build docker-push \
517+
acceptance/setup acceptance/tests
518+
```
477519
# Alternatives
478520

479521
The following is a list of alternative solutions that may better fit you depending on your use-case:

acceptance/checks.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,6 @@ echo Found pod ${pod_name}.
2424

2525
echo Waiting for pod ${runner_name} to become ready... 1>&2
2626

27-
kubectl wait pod/${runner_name} --for condition=ready --timeout 120s
27+
kubectl wait pod/${runner_name} --for condition=ready --timeout 180s
2828

2929
echo All tests passed. 1>&2

acceptance/deploy.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ else
3232
kubectl apply \
3333
-n actions-runner-system \
3434
-f release/actions-runner-controller.yaml
35-
kubectl -n actions-runner-system wait deploy/controller-manager --for condition=available
35+
kubectl -n actions-runner-system wait deploy/controller-manager --for condition=available --timeout 60s
3636
fi
3737

3838
# Adhocly wait for some time until actions-runner-controller's admission webhook gets ready

controllers/runner_controller.go

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ package controllers
1919
import (
2020
"context"
2121
"fmt"
22-
"reflect"
22+
"github.com/summerwind/actions-runner-controller/hash"
2323
"strings"
2424

2525
"github.com/go-logr/logr"
@@ -39,6 +39,8 @@ import (
3939
const (
4040
containerName = "runner"
4141
finalizerName = "runner.actions.summerwind.dev"
42+
43+
LabelKeyPodTemplateHash = "pod-template-hash"
4244
)
4345

4446
// RunnerReconciler reconciles a Runner object
@@ -198,11 +200,12 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
198200
return ctrl.Result{}, nil
199201
}
200202

201-
// Filter out token that is changed hourly.
202-
currentEnvValues := filterEnvVars(pod.Spec.Containers[0].Env, "RUNNER_TOKEN")
203-
newEnvValues := filterEnvVars(newPod.Spec.Containers[0].Env, "RUNNER_TOKEN")
203+
// See the `newPod` function called above for more information
204+
// about when this hash changes.
205+
curHash := pod.Labels[LabelKeyPodTemplateHash]
206+
newHash := newPod.Labels[LabelKeyPodTemplateHash]
204207

205-
if !runnerBusy && (!reflect.DeepEqual(currentEnvValues, newEnvValues) || pod.Spec.Containers[0].Image != newPod.Spec.Containers[0].Image) {
208+
if !runnerBusy && curHash != newHash {
206209
restart = true
207210
}
208211

@@ -363,11 +366,44 @@ func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
363366
}
364367

365368
env = append(env, runner.Spec.Env...)
369+
370+
labels := map[string]string{}
371+
372+
for k, v := range runner.Labels {
373+
labels[k] = v
374+
}
375+
376+
// This implies that...
377+
//
378+
// (1) We recreate the runner pod whenever the runner has changes in:
379+
// - metadata.labels (excluding "runner-template-hash" added by the parent RunnerReplicaSet
380+
// - metadata.annotations
381+
// - metadata.spec (including image, env, organization, repository, group, and so on)
382+
// - GithubBaseURL setting of the controller (can be configured via GITHUB_ENTERPRISE_URL)
383+
//
384+
// (2) We don't recreate the runner pod when there are changes in:
385+
// - runner.status.registration.token
386+
// - This token expires and changes hourly, but you don't need to recreate the pod due to that.
387+
// It's the opposite.
388+
// An unexpired token is required only when the runner agent is registering itself on launch.
389+
//
390+
// In other words, the registered runner doesn't get invalidated on registration token expiration.
391+
// A registered runner's session and the a registration token seem to have two different and independent
392+
// lifecycles.
393+
//
394+
// See https://github.com/summerwind/actions-runner-controller/issues/143 for more context.
395+
labels[LabelKeyPodTemplateHash] = hash.FNVHashStringObjects(
396+
filterLabels(runner.Labels, LabelKeyRunnerTemplateHash),
397+
runner.Annotations,
398+
runner.Spec,
399+
r.GitHubClient.GithubBaseURL,
400+
)
401+
366402
pod := corev1.Pod{
367403
ObjectMeta: metav1.ObjectMeta{
368404
Name: runner.Name,
369405
Namespace: runner.Namespace,
370-
Labels: runner.Labels,
406+
Labels: labels,
371407
Annotations: runner.Annotations,
372408
},
373409
Spec: corev1.PodSpec{

controllers/utils.go

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
package controllers
22

3-
import (
4-
corev1 "k8s.io/api/core/v1"
5-
)
3+
func filterLabels(labels map[string]string, filter string) map[string]string {
4+
filtered := map[string]string{}
65

7-
func filterEnvVars(envVars []corev1.EnvVar, filter string) (filtered []corev1.EnvVar) {
8-
for _, envVar := range envVars {
9-
if envVar.Name != filter {
10-
filtered = append(filtered, envVar)
6+
for k, v := range labels {
7+
if k != filter {
8+
filtered[k] = v
119
}
1210
}
11+
1312
return filtered
1413
}

controllers/utils_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package controllers
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func Test_filterLabels(t *testing.T) {
9+
type args struct {
10+
labels map[string]string
11+
filter string
12+
}
13+
tests := []struct {
14+
name string
15+
args args
16+
want map[string]string
17+
}{
18+
{
19+
name: "ok",
20+
args: args{
21+
labels: map[string]string{LabelKeyRunnerTemplateHash: "abc", LabelKeyPodTemplateHash: "def"},
22+
filter: LabelKeyRunnerTemplateHash,
23+
},
24+
want: map[string]string{LabelKeyPodTemplateHash: "def"},
25+
},
26+
}
27+
for _, tt := range tests {
28+
t.Run(tt.name, func(t *testing.T) {
29+
if got := filterLabels(tt.args.labels, tt.args.filter); !reflect.DeepEqual(got, tt.want) {
30+
t.Errorf("filterLabels() = %v, want %v", got, tt.want)
31+
}
32+
})
33+
}
34+
}

hack/make-env.sh

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env bash
2+
3+
COMMIT=$(git rev-parse HEAD)
4+
TAG=$(git describe --exact-match --abbrev=0 --tags "${COMMIT}" 2> /dev/null || true)
5+
BRANCH=$(git branch | grep \* | cut -d ' ' -f2 | sed -e 's/[^a-zA-Z0-9+=._:/-]*//g' || true)
6+
VERSION=""
7+
8+
if [ -z "$TAG" ]; then
9+
[[ -n "$BRANCH" ]] && VERSION="${BRANCH}-"
10+
VERSION="${VERSION}${COMMIT:0:8}"
11+
else
12+
VERSION=$TAG
13+
fi
14+
15+
if [ -n "$(git diff --shortstat 2> /dev/null | tail -n1)" ]; then
16+
VERSION="${VERSION}-dirty"
17+
fi
18+
19+
export VERSION

hash/fnv.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package hash
2+
3+
import (
4+
"fmt"
5+
"hash/fnv"
6+
"k8s.io/apimachinery/pkg/util/rand"
7+
)
8+
9+
func FNVHashStringObjects(objs ...interface{}) string {
10+
hash := fnv.New32a()
11+
12+
for _, obj := range objs {
13+
DeepHashObject(hash, obj)
14+
}
15+
16+
return rand.SafeEncodeString(fmt.Sprint(hash.Sum32()))
17+
}

hash/hash.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Copyright 2015 The Kubernetes Authors.
2+
// hash.go is copied from kubernetes's pkg/util/hash.go
3+
// See https://github.com/kubernetes/kubernetes/blob/e1c617a88ec286f5f6cb2589d6ac562d095e1068/pkg/util/hash/hash.go#L25-L37
4+
5+
package hash
6+
7+
import (
8+
"hash"
9+
10+
"github.com/davecgh/go-spew/spew"
11+
)
12+
13+
// DeepHashObject writes specified object to hash using the spew library
14+
// which follows pointers and prints actual values of the nested objects
15+
// ensuring the hash does not change when a pointer changes.
16+
func DeepHashObject(hasher hash.Hash, objectToWrite interface{}) {
17+
hasher.Reset()
18+
printer := spew.ConfigState{
19+
Indent: " ",
20+
SortKeys: true,
21+
DisableMethods: true,
22+
SpewKeys: true,
23+
}
24+
printer.Fprintf(hasher, "%#v", objectToWrite)
25+
}

0 commit comments

Comments
 (0)