Compare commits

..

12 Commits

Author SHA1 Message Date
Moto Ishizawa
f2d3ca672f Unset environment variables for runner config 2020-02-06 22:15:26 +09:00
Moto Ishizawa
829a167303 Add 'env' field to runner resource 2020-02-06 22:09:07 +09:00
Moto Ishizawa
c66916a4ee Use dumb-init to handle signal properly 2020-02-06 18:47:50 +09:00
Moto Ishizawa
f5c8a0e655 Update README 2020-02-03 21:52:28 +09:00
Moto Ishizawa
a436216d5e Implement finalizer 2020-02-03 21:35:01 +09:00
Moto Ishizawa
497ddba82d Record event of runner resource 2020-02-03 18:40:59 +09:00
Moto Ishizawa
10f6cb5e90 Add additional printer columns 2020-02-03 17:37:48 +09:00
Moto Ishizawa
13ef78ce20 Sync runner status with pod status 2020-02-03 17:25:38 +09:00
Moto Ishizawa
0061979e3e Add '-github-token' flag 2020-02-03 17:02:27 +09:00
Moto Ishizawa
e6952f5ca1 Add '-runner-image' and '-docker-image' flags 2020-02-03 16:56:52 +09:00
Moto Ishizawa
ffdbe5cee9 Set container image version properly in the release task 2020-02-03 16:55:38 +09:00
Moto Ishizawa
4970814b6c Do not run build workflow when .github changed 2020-02-03 16:54:49 +09:00
10 changed files with 366 additions and 55 deletions

View File

@@ -4,6 +4,7 @@ on:
- master
paths-ignore:
- 'runner/**'
- '.github/**'
jobs:
build:

View File

@@ -64,6 +64,7 @@ docker-push:
# Generate the release manifest file
release: manifests
cd config/manager && kustomize edit set image controller=${NAME}:${VERSION}
mkdir -p release
kustomize build config/default > release/actions-runner-controller.yaml

View File

@@ -13,7 +13,7 @@ This controller operates self-hosted runners for GitHub Actions on your Kubernet
First, install *actions-runner-controller* with a manifest file. This will create a *actions-runner-system* namespace in your Kubernetes and deploy the required resources.
```
$ kubectl -f https://github.com/summerwind/actions-runner-controller/releases/download/latest/actions-runner-controller.yaml
$ kubectl apply -f https://github.com/summerwind/actions-runner-controller/releases/latest/download/actions-runner-controller.yaml
```
Set your access token of GitHub to the secret. `${GITHUB_TOKEN}` is the value you must replace with your access token. This token is used to register Self-hosted runner by *actions-runner-controller*.
@@ -27,9 +27,7 @@ $ kubectl create secret generic controller-manager --from-literal=github_token=$
To launch Self-hosted runner, you need to create a manifest file includes *Runner* resource as follows. This example launches a self-hosted runner with name *example-runner* for the *summerwind/actions-runner-controller* repository.
```
$ vim runner.yaml
```
```
# runner.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: Runner
metadata:
@@ -42,14 +40,15 @@ Apply the created manifest file to your Kubernetes.
```
$ kubectl apply -f runner.yaml
runner.actions.summerwind.dev/example-runner created
```
You can see that the Runner resource has been created.
```
$ kubectl get runners
NAME AGE
example-runner 1m
NAME REPOSITORY STATUS
example-runner summerwind/actions-runner-controller Running
```
You can also see that the runner pod has been running.
@@ -64,4 +63,4 @@ The runner you created has been registerd to your repository.
<img width="756" alt="Actions tab in your repository settings" src="https://user-images.githubusercontent.com/230145/73618667-8cbf9700-466c-11ea-80b6-c67e6d3f70e7.png">
Now your can use your self-hosted runner. See the [documentation](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/using-self-hosted-runners-in-a-workflow) on how to run a job with it.
Now your can use your self-hosted runner. See the [official documentation](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/using-self-hosted-runners-in-a-workflow) on how to run a job with it.

View File

@@ -17,6 +17,7 @@ limitations under the License.
package v1alpha1
import (
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
@@ -28,14 +29,17 @@ type RunnerSpec struct {
// +optional
Image string `json:"image"`
// +optional
Env []corev1.EnvVar `json:"env"`
}
// RunnerStatus defines the observed state of Runner
type RunnerStatus struct {
Registration RunnerStatusRegistration `json:"registration"`
Phase string `json:"Phase"`
Reason string `json:"Reason"`
Message string `json:"Message"`
Phase string `json:"phase"`
Reason string `json:"reason"`
Message string `json:"message"`
}
type RunnerStatusRegistration struct {
@@ -46,6 +50,8 @@ type RunnerStatusRegistration struct {
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:JSONPath=".spec.repository",name=Repository,type=string
// +kubebuilder:printcolumn:JSONPath=".status.phase",name=Status,type=string
// Runner is the Schema for the runners API
type Runner struct {

View File

@@ -21,6 +21,7 @@ limitations under the License.
package v1alpha1
import (
"k8s.io/api/core/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
)
@@ -29,7 +30,7 @@ func (in *Runner) DeepCopyInto(out *Runner) {
*out = *in
out.TypeMeta = in.TypeMeta
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
out.Spec = in.Spec
in.Spec.DeepCopyInto(&out.Spec)
in.Status.DeepCopyInto(&out.Status)
}
@@ -86,6 +87,13 @@ func (in *RunnerList) DeepCopyObject() runtime.Object {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *RunnerSpec) DeepCopyInto(out *RunnerSpec) {
*out = *in
if in.Env != nil {
in, out := &in.Env, &out.Env
*out = make([]v1.EnvVar, len(*in))
for i := range *in {
(*in)[i].DeepCopyInto(&(*out)[i])
}
}
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RunnerSpec.

View File

@@ -8,6 +8,13 @@ metadata:
creationTimestamp: null
name: runners.actions.summerwind.dev
spec:
additionalPrinterColumns:
- JSONPath: .spec.repository
name: Repository
type: string
- JSONPath: .status.phase
name: Status
type: string
group: actions.summerwind.dev
names:
kind: Runner
@@ -36,6 +43,103 @@ spec:
spec:
description: RunnerSpec defines the desired state of Runner
properties:
env:
items:
description: EnvVar represents an environment variable present in
a Container.
properties:
name:
description: Name of the environment variable. Must be a C_IDENTIFIER.
type: string
value:
description: 'Variable references $(VAR_NAME) are expanded using
the previous defined environment variables in the container
and any service environment variables. If a variable cannot
be resolved, the reference in the input string will be unchanged.
The $(VAR_NAME) syntax can be escaped with a double $$, ie:
$$(VAR_NAME). Escaped references will never be expanded, regardless
of whether the variable exists or not. Defaults to "".'
type: string
valueFrom:
description: Source for the environment variable's value. Cannot
be used if value is not empty.
properties:
configMapKeyRef:
description: Selects a key of a ConfigMap.
properties:
key:
description: The key to select.
type: string
name:
description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
TODO: Add other useful fields. apiVersion, kind, uid?'
type: string
optional:
description: Specify whether the ConfigMap or its key
must be defined
type: boolean
required:
- key
type: object
fieldRef:
description: 'Selects a field of the pod: supports metadata.name,
metadata.namespace, metadata.labels, metadata.annotations,
spec.nodeName, spec.serviceAccountName, status.hostIP, status.podIP.'
properties:
apiVersion:
description: Version of the schema the FieldPath is written
in terms of, defaults to "v1".
type: string
fieldPath:
description: Path of the field to select in the specified
API version.
type: string
required:
- fieldPath
type: object
resourceFieldRef:
description: 'Selects a resource of the container: only resources
limits and requests (limits.cpu, limits.memory, limits.ephemeral-storage,
requests.cpu, requests.memory and requests.ephemeral-storage)
are currently supported.'
properties:
containerName:
description: 'Container name: required for volumes, optional
for env vars'
type: string
divisor:
description: Specifies the output format of the exposed
resources, defaults to "1"
type: string
resource:
description: 'Required: resource to select'
type: string
required:
- resource
type: object
secretKeyRef:
description: Selects a key of a secret in the pod's namespace
properties:
key:
description: The key of the secret to select from. Must
be a valid secret key.
type: string
name:
description: 'Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
TODO: Add other useful fields. apiVersion, kind, uid?'
type: string
optional:
description: Specify whether the Secret or its key must
be defined
type: boolean
required:
- key
type: object
type: object
required:
- name
type: object
type: array
image:
type: string
repository:
@@ -48,11 +152,11 @@ spec:
status:
description: RunnerStatus defines the observed state of Runner
properties:
Message:
message:
type: string
Phase:
phase:
type: string
Reason:
reason:
type: string
registration:
properties:
@@ -69,9 +173,9 @@ spec:
- token
type: object
required:
- Message
- Phase
- Reason
- message
- phase
- reason
- registration
type: object
type: object

View File

@@ -26,6 +26,7 @@ import (
"github.com/google/go-github/v29/github"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -36,11 +37,18 @@ import (
)
const (
defaultImage = "summerwind/actions-runner:latest"
containerName = "runner"
finalizerName = "runner.actions.summerwind.dev"
)
type RegistrationToken struct {
type GitHubRunner struct {
ID int `json:"id"`
Name string `json:"name"`
OS string `json:"os"`
Status string `json:"status"`
}
type GitHubRegistrationToken struct {
Token string `json:"token"`
ExpiresAt string `json:"expires_at"`
}
@@ -49,8 +57,11 @@ type RegistrationToken struct {
type RunnerReconciler struct {
client.Client
Log logr.Logger
Recorder record.EventRecorder
Scheme *runtime.Scheme
GitHubClient *github.Client
RunnerImage string
DockerImage string
}
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=runners,verbs=get;list;watch;create;update;patch;delete
@@ -63,14 +74,56 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
var runner v1alpha1.Runner
if err := r.Get(ctx, req.NamespacedName, &runner); err != nil {
log.Error(err, "Unable to fetch Runner")
return ctrl.Result{}, client.IgnoreNotFound(err)
}
if runner.ObjectMeta.DeletionTimestamp.IsZero() {
finalizers, added := addFinalizer(runner.ObjectMeta.Finalizers)
if added {
newRunner := runner.DeepCopy()
newRunner.ObjectMeta.Finalizers = finalizers
if err := r.Update(ctx, newRunner); err != nil {
log.Error(err, "Failed to update runner")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
} else {
finalizers, removed := removeFinalizer(runner.ObjectMeta.Finalizers)
if removed {
ok, err := r.unregisterRunner(ctx, runner.Spec.Repository, runner.Name)
if err != nil {
log.Error(err, "Failed to unregister runner")
return ctrl.Result{}, err
}
if !ok {
log.V(1).Info("Runner no longer exists on GitHub")
}
newRunner := runner.DeepCopy()
newRunner.ObjectMeta.Finalizers = finalizers
if err := r.Update(ctx, newRunner); err != nil {
log.Error(err, "Failed to update runner")
return ctrl.Result{}, err
}
log.Info("Removed runner from GitHub", "repository", runner.Spec.Repository)
}
return ctrl.Result{}, nil
}
if !runner.IsRegisterable() {
reg, err := r.newRegistration(ctx, runner.Spec.Repository)
if err != nil {
log.Error(err, "Failed to get new registration")
r.Recorder.Event(&runner, corev1.EventTypeWarning, "FailedUpdateRegistrationToken", "Updating registration token failed")
log.Error(err, "Failed to get new registration token")
return ctrl.Result{}, err
}
@@ -78,10 +131,11 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
updated.Status.Registration = reg
if err := r.Status().Update(ctx, updated); err != nil {
log.Error(err, "Unable to update Runner status")
log.Error(err, "Failed to update runner status")
return ctrl.Result{}, err
}
r.Recorder.Event(&runner, corev1.EventTypeNormal, "RegistrationTokenUpdated", "Successfully update registration token")
log.Info("Updated registration token", "repository", runner.Spec.Repository)
return ctrl.Result{}, nil
}
@@ -94,17 +148,32 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
newPod, err := r.newPod(runner)
if err != nil {
log.Error(err, "could not create pod")
log.Error(err, "Could not create pod")
return ctrl.Result{}, err
}
if err := r.Create(ctx, &newPod); err != nil {
log.Error(err, "failed to create pod resource")
log.Error(err, "Failed to create pod resource")
return ctrl.Result{}, err
}
log.Info("Created a runner pod", "repository", runner.Spec.Repository)
r.Recorder.Event(&runner, corev1.EventTypeNormal, "PodCreated", fmt.Sprintf("Created pod '%s'", newPod.Name))
log.Info("Created runner pod", "repository", runner.Spec.Repository)
} else {
if runner.Status.Phase != string(pod.Status.Phase) {
updated := runner.DeepCopy()
updated.Status.Phase = string(pod.Status.Phase)
updated.Status.Reason = pod.Status.Reason
updated.Status.Message = pod.Status.Message
if err := r.Status().Update(ctx, updated); err != nil {
log.Error(err, "Failed to update runner status")
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
if !pod.ObjectMeta.DeletionTimestamp.IsZero() {
return ctrl.Result{}, err
}
@@ -125,7 +194,7 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
newPod, err := r.newPod(runner)
if err != nil {
log.Error(err, "could not create pod")
log.Error(err, "Could not create pod")
return ctrl.Result{}, err
}
@@ -140,11 +209,12 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
}
if err := r.Delete(ctx, &pod); err != nil {
log.Error(err, "failed to delete pod resource")
log.Error(err, "Failed to delete pod resource")
return ctrl.Result{}, err
}
log.Info("Restarted a runner pod", "repository", runner.Spec.Repository)
r.Recorder.Event(&runner, corev1.EventTypeNormal, "PodDeleted", fmt.Sprintf("Deleted pod '%s'", newPod.Name))
log.Info("Deleted runner pod", "repository", runner.Spec.Repository)
}
return ctrl.Result{}, nil
@@ -170,8 +240,8 @@ func (r *RunnerReconciler) newRegistration(ctx context.Context, repo string) (v1
return reg, err
}
func (r *RunnerReconciler) getRegistrationToken(ctx context.Context, repo string) (RegistrationToken, error) {
var regToken RegistrationToken
func (r *RunnerReconciler) getRegistrationToken(ctx context.Context, repo string) (GitHubRegistrationToken, error) {
var regToken GitHubRegistrationToken
req, err := r.GitHubClient.NewRequest("POST", fmt.Sprintf("/repos/%s/actions/runners/registration-token", repo), nil)
if err != nil {
@@ -190,30 +260,81 @@ func (r *RunnerReconciler) getRegistrationToken(ctx context.Context, repo string
return regToken, nil
}
func (r *RunnerReconciler) unregisterRunner(ctx context.Context, repo, name string) (bool, error) {
runners, err := r.listRunners(ctx, repo)
if err != nil {
return false, err
}
id := 0
for _, runner := range runners {
if runner.Name == name {
id = runner.ID
break
}
}
if id == 0 {
return false, nil
}
if err := r.removeRunner(ctx, repo, id); err != nil {
return false, err
}
return true, nil
}
func (r *RunnerReconciler) listRunners(ctx context.Context, repo string) ([]GitHubRunner, error) {
runners := []GitHubRunner{}
req, err := r.GitHubClient.NewRequest("GET", fmt.Sprintf("/repos/%s/actions/runners", repo), nil)
if err != nil {
return runners, err
}
res, err := r.GitHubClient.Do(ctx, req, &runners)
if err != nil {
return runners, err
}
if res.StatusCode != 200 {
return runners, fmt.Errorf("unexpected status: %d", res.StatusCode)
}
return runners, nil
}
func (r *RunnerReconciler) removeRunner(ctx context.Context, repo string, id int) error {
req, err := r.GitHubClient.NewRequest("DELETE", fmt.Sprintf("/repos/%s/actions/runners/%d", repo, id), nil)
if err != nil {
return err
}
res, err := r.GitHubClient.Do(ctx, req, nil)
if err != nil {
return err
}
if res.StatusCode != 204 {
return fmt.Errorf("unexpected status: %d", res.StatusCode)
}
return nil
}
func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
var (
privileged bool = true
group int64 = 0
)
image := runner.Spec.Image
if image == "" {
image = defaultImage
runnerImage := runner.Spec.Image
if runnerImage == "" {
runnerImage = r.RunnerImage
}
pod := corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: runner.Name,
Namespace: runner.Namespace,
},
Spec: corev1.PodSpec{
RestartPolicy: "OnFailure",
Containers: []corev1.Container{
{
Name: containerName,
Image: image,
ImagePullPolicy: "Always",
Env: []corev1.EnvVar{
env := []corev1.EnvVar{
{
Name: "RUNNER_NAME",
Value: runner.Name,
@@ -226,7 +347,22 @@ func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
Name: "RUNNER_TOKEN",
Value: runner.Status.Registration.Token,
},
}
env = append(env, runner.Spec.Env...)
pod := corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: runner.Name,
Namespace: runner.Namespace,
},
Spec: corev1.PodSpec{
RestartPolicy: "OnFailure",
Containers: []corev1.Container{
{
Name: containerName,
Image: runnerImage,
ImagePullPolicy: "Always",
Env: env,
VolumeMounts: []corev1.VolumeMount{
{
Name: "docker",
@@ -239,7 +375,7 @@ func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
},
{
Name: "docker",
Image: "docker:19.03.5-dind",
Image: r.DockerImage,
VolumeMounts: []corev1.VolumeMount{
{
Name: "docker",
@@ -270,8 +406,40 @@ func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
}
func (r *RunnerReconciler) SetupWithManager(mgr ctrl.Manager) error {
r.Recorder = mgr.GetEventRecorderFor("runner-controller")
return ctrl.NewControllerManagedBy(mgr).
For(&v1alpha1.Runner{}).
Owns(&corev1.Pod{}).
Complete(r)
}
func addFinalizer(finalizers []string) ([]string, bool) {
exists := false
for _, name := range finalizers {
if name == finalizerName {
exists = true
}
}
if exists {
return finalizers, false
}
return append(finalizers, finalizerName), true
}
func removeFinalizer(finalizers []string) ([]string, bool) {
removed := false
result := []string{}
for _, name := range finalizers {
if name == finalizerName {
removed = true
continue
}
result = append(result, name)
}
return result, removed
}

27
main.go
View File

@@ -34,6 +34,11 @@ import (
// +kubebuilder:scaffold:imports
)
const (
defaultRunnerImage = "summerwind/actions-runner:v2.165.1"
defaultDockerImage = "docker:19.03.5-dind"
)
var (
scheme = runtime.NewScheme()
setupLog = ctrl.Log.WithName("setup")
@@ -47,16 +52,28 @@ func init() {
}
func main() {
var metricsAddr string
var enableLeaderElection bool
var (
metricsAddr string
enableLeaderElection bool
runnerImage string
dockerImage string
ghToken string
)
flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.")
flag.BoolVar(&enableLeaderElection, "enable-leader-election", false,
"Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.")
flag.StringVar(&runnerImage, "runner-image", defaultRunnerImage, "The image name of self-hosted runner container.")
flag.StringVar(&dockerImage, "docker-image", defaultDockerImage, "The image name of docker sidecar container.")
flag.StringVar(&ghToken, "github-token", "", "The access token of GitHub.")
flag.Parse()
ghToken := os.Getenv("GITHUB_TOKEN")
if ghToken == "" {
fmt.Fprintln(os.Stderr, "Error: access token is not specified in the environment variable 'GITHUB_TOKEN'")
ghToken = os.Getenv("GITHUB_TOKEN")
}
if ghToken == "" {
fmt.Fprintln(os.Stderr, "Error: GitHub access token must be specified.")
os.Exit(1)
}
@@ -85,6 +102,8 @@ func main() {
Log: ctrl.Log.WithName("controllers").WithName("Runner"),
Scheme: mgr.GetScheme(),
GitHubClient: ghClient,
RunnerImage: runnerImage,
DockerImage: dockerImage,
}
if err = runnerReconciler.SetupWithManager(mgr); err != nil {

View File

@@ -9,6 +9,8 @@ RUN apt update \
&& tar zxvf docker.tgz \
&& install -o root -g root -m 755 docker/docker /usr/local/bin/docker \
&& rm -rf docker docker.tgz \
&& curl -L -o /usr/local/bin/dumb-init https://github.com/Yelp/dumb-init/releases/download/v1.2.2/dumb-init_1.2.2_amd64 \
&& chmod +x /usr/local/bin/dumb-init \
&& adduser --disabled-password --gecos "" --uid 1000 runner
RUN mkdir -p /runner \
@@ -21,4 +23,5 @@ RUN mkdir -p /runner \
COPY entrypoint.sh /runner
USER runner:runner
ENTRYPOINT ["/runner/entrypoint.sh"]
ENTRYPOINT ["/usr/local/bin/dumb-init", "--"]
CMD ["/runner/entrypoint.sh"]

View File

@@ -17,4 +17,6 @@ fi
cd /runner
./config.sh --unattended --replace --name "${RUNNER_NAME}" --url "https://github.com/${RUNNER_REPO}" --token "${RUNNER_TOKEN}"
./run.sh --once
unset RUNNER_NAME RUNNER_REPO RUNNER_TOKEN
exec ./run.sh --once