Compare commits

..

48 Commits

Author SHA1 Message Date
Moto Ishizawa
390f2a62d9 Merge pull request #50 from summerwind/runner-validation-webhook
Add validation webhooks
2020-05-08 22:39:13 +09:00
naka-gawa
1555651325 fix typo readme 2020-05-06 12:25:30 +09:00
Moto Ishizawa
e7445e286f Merge pull request #52 from reiniertimmer/add-git-unzip-to-runner
Add dependencies from GitHub virtual environment to runner
2020-05-04 22:34:38 +09:00
Reinier Timmer
79655989d0 Add additional software packages to runner image 2020-05-04 06:55:16 +00:00
Moto Ishizawa
55323c3754 Update installation section 2020-05-02 16:57:19 +09:00
Moto Ishizawa
f80c3c1928 Set volume to pod properly 2020-05-01 08:51:25 +09:00
Moto Ishizawa
9a86812214 Add manifests for validation webhook 2020-04-30 22:12:39 +09:00
Moto Ishizawa
e889eaeb04 Add validation webhooks 2020-04-30 22:11:59 +09:00
Reinier Timmer
b96979888c fix delete pod when runner failed to register 2020-04-29 14:23:58 +09:00
Moto Ishizawa
7df119e470 Merge pull request #44 from reiniertimmer/organization-runners
support for organization runners & labels
2020-04-28 22:38:35 +09:00
Reinier Timmer
966e0dca37 fix merge conflict on README 2020-04-28 11:24:59 +02:00
Reinier Timmer
8c42b317ec updated documentation 2020-04-28 11:15:41 +02:00
Reinier Timmer
9f57f52e36 organization and repository are now exclusive 2020-04-28 11:14:31 +02:00
Reinier Timmer
8c5b776807 support runner labels 2020-04-28 11:14:31 +02:00
Reinier Timmer
2567f6ee4e omit empty repository from runner spec 2020-04-28 11:14:31 +02:00
Reinier Timmer
eca3cc7941 add organization info to runner status 2020-04-28 11:14:31 +02:00
Reinier Timmer
75d15ee91b backwards compatibility of dockerfile 2020-04-28 11:14:31 +02:00
Reinier Timmer
fb35dd4131 support for organization runners 2020-04-28 11:14:31 +02:00
Moto Ishizawa
d1429beaa6 Merge pull request #45 from summerwind/share-work-dir
Share runner's working directory with docker sidecar
2020-04-25 10:08:04 +09:00
Moto Ishizawa
3b8ea2991c Share runner's working directory with docker sidecar 2020-04-24 22:36:27 +09:00
Moto Ishizawa
6bdfb5cff6 Merge pull request #41 from arhue/improve-readme
Improve readme
2020-04-23 19:59:28 +09:00
Varun Priolkar
df5eed52d0 Specify language in additional tweaks code block 2020-04-23 12:57:56 +05:30
Varun Priolkar
5f271a3050 Fix code block on additional tweak section 2020-04-23 12:57:15 +05:30
Varun Priolkar
6997cc97c6 Improve readme 2020-04-23 12:53:46 +05:30
Moto Ishizawa
427cc506e1 Merge pull request #36 from summerwind/runner-v2.169.1
Update runner to v2.169.1
2020-04-18 10:41:34 +09:00
Moto Ishizawa
13616ba1b2 Use latest container image 2020-04-16 19:16:15 +09:00
Moto Ishizawa
d8327e9ab8 Update runner to v2.169.1 2020-04-16 19:16:07 +09:00
Moto Ishizawa
fd1b72e4ed Merge pull request #35 from chenrui333/default-docker-image-to-19.03.8
Make defaultDockerImage to 19.03.8
2020-04-16 17:55:02 +09:00
Rui Chen
79f15b4906 Make defaultDockerImage to 19.03.8 2020-04-15 19:47:07 -04:00
Moto Ishizawa
5714459c24 Merge pull request #31 from summerwind/github-package
Use github package to access the GitHub API
2020-04-14 16:30:26 +09:00
Moto Ishizawa
ab28dde0ec Add github package to container image 2020-04-13 22:29:48 +09:00
Moto Ishizawa
3ccc51433f Use github package to access the GitHub API 2020-04-13 22:28:07 +09:00
Moto Ishizawa
5f608058cd Add github package 2020-04-13 22:27:05 +09:00
Moto Ishizawa
a91df5c564 Merge pull request #29 from summerwind/add-github-apps-doc
Add section to use GitHub App
2020-04-11 21:33:37 +09:00
Moto Ishizawa
0bb6f64470 Add section to use GitHub App 2020-04-10 15:42:27 +09:00
Moto Ishizawa
ce40635d1e Merge pull request #28 from chenrui333/update-default-base-image
Update defaultRunnerImage to v2.168.0
2020-04-10 14:12:07 +09:00
Rui Chen
52c0f2e4f3 Update defaultRunnerImage to v2.168.0 2020-04-09 20:16:52 -04:00
Yusuke Kuoka
b411d37f2b fix: RunnerDeployment should clean up old RunnerReplicaSets ASAP
Since the initial implementation of RunnerDeployment and until this change, any update to a runner deployment has been leaving old runner replicasets until the next resync interval. This fixes that, by continusouly retrying the reconcilation 10 seconds later to see if there are any old runner replicasets that can be removed.

In addition to that, the cleanup of old runner replicasets has been improved to be deferred until all the runners of the newest replica set to be available. This gives you hopefully zero or at less downtime updates of runner deployments.

Fixes #24
2020-04-04 07:55:12 +09:00
Vito Botta
a19cd373db Bump Docker version 2020-04-02 08:32:27 +09:00
Vito Botta
f2dcb5659d Add runner user to sudo group 2020-04-02 08:32:27 +09:00
Moto Ishizawa
b8b4ef4b60 Merge pull request #21 from summerwind/add-permission-events
Add permission to create/patch events resource
2020-03-28 22:18:58 +09:00
Moto Ishizawa
cac199f16e Merge pull request #20 from summerwind/github-apps-support
Add support of GitHub Apps authentication
2020-03-28 22:18:36 +09:00
Moto Ishizawa
5efdc6efe6 Add permission to create/patch events resource 2020-03-27 23:25:37 +09:00
Moto Ishizawa
af81c7f4c9 Add environment variables and volumes for GitHub Apps credentials 2020-03-26 23:12:54 +09:00
Moto Ishizawa
80122a56d7 Add flags for GitHub Apps credentials 2020-03-26 23:12:11 +09:00
Adam Jensen
934ec7f181 Clarify instructions for getting a token (#18)
* Clarify instructions for getting a token

* Fix typo
2020-03-25 21:22:19 +09:00
Moto Ishizawa
49160138ab Merge pull request #19 from summerwind/actions-runner-v2.168.0
Update runner to v2.168.0
2020-03-25 17:25:07 +09:00
Moto Ishizawa
fac211f5d9 Update runner to v2.168.0 2020-03-25 17:10:25 +09:00
27 changed files with 1511 additions and 228 deletions

View File

@@ -13,6 +13,7 @@ RUN go mod download
COPY main.go main.go
COPY api/ api/
COPY controllers/ controllers/
COPY github/ github/
# Build
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 GO111MODULE=on go build -a -o manager main.go

221
README.md
View File

@@ -4,34 +4,98 @@ This controller operates self-hosted runners for GitHub Actions on your Kubernet
## Motivation
[GitHub Actions](https://github.com/features/actions) is very useful as a tool for automating development. GitHub Actions job is run in the cloud by default, but you may want to run your jobs in your environment. [Self-hosted runner](https://github.com/actions/runner) can be used for such use cases, but requires the provision of a virtual machine instance and configuration. If you already have a Kubernetes cluster, you'll want to run the self-hosted runner on top of it.
[GitHub Actions](https://github.com/features/actions) is a very useful tool for automating development. GitHub Actions jobs are run in the cloud by default, but you may want to run your jobs in your environment. [Self-hosted runner](https://github.com/actions/runner) can be used for such use cases, but requires the provisioning and configuration of a virtual machine instance. Instead if you already have a Kubernetes cluster, it makes more sense to run the self-hosted runner on top of it.
*actions-runner-controller* makes that possible. Just create a *Runner* resource on your Kubernetes, and it will run and operate the self-hosted runner of the specified repository. Combined with Kubernetes RBAC, you can also build simple Self-hosted runners as a Service.
**actions-runner-controller** makes that possible. Just create a *Runner* resource on your Kubernetes, and it will run and operate the self-hosted runner for the specified repository. Combined with Kubernetes RBAC, you can also build simple Self-hosted runners as a Service.
## Installation
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.
actions-runner-controller uses [cert-manager](https://cert-manager.io/docs/installation/kubernetes/) for certificate management of Admission Webhook. Make sure you have already installed cert-manager before you install. The installation instructions for cert-manager can be found below.
- [Installing cert-manager on Kubernetes](https://cert-manager.io/docs/installation/kubernetes/)
Install the custom resource and actions-runner-controller itself. This will create actions-runner-system namespace in your Kubernetes and deploy the required resources.
```
$ 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*.
## Setting up authentication with GitHub API
There are two ways for actions-runner-controller to authenticate with the the GitHub API:
1. Using GitHub App.
2. Using Personal Access Token.
**NOTE: It is extremely important to only follow one of the sections below and not both.**
### Using GitHub App
You can create a GitHub App for either your account or any organization. If you want to create a GitHub App for your account, open the following link to the creation page, enter any unique name in the "GitHub App name" field, and hit the "Create GitHub App" button at the bottom of the page.
- [Create GitHub Apps on your account](https://github.com/settings/apps/new?url=http://github.com/summerwind/actions-runner-controller&webhook_active=false&public=false&administration=write)
If you want to create a GitHub App for your organization, replace the `:org` part of the following URL with your organization name before opening it. Then enter any unique name in the "GitHub App name" field, and hit the "Create GitHub App" button at the bottom of the page to create a GitHub App.
- [Create GitHub Apps on your organization](https://github.com/organizations/:org/settings/apps/new?url=http://github.com/summerwind/actions-runner-controller&webhook_active=false&public=false&administration=write)
You will see an *App ID* on the page of the GitHub App you created as follows, the value of this App ID will be used later.
<img width="750" alt="App ID" src="https://user-images.githubusercontent.com/230145/78968802-6e7c8880-7b40-11ea-8b08-0c1b8e6a15f0.png">
Download the private key file by pushing the "Generate a private key" button at the bottom of the GitHub App page. This file will also be used later.
<img width="750" alt="Generate a private key" src="https://user-images.githubusercontent.com/230145/78968805-71777900-7b40-11ea-97e6-55c48dfc44ac.png">
Go to the "Install App" tab on the left side of the page and install the GitHub App that you created for your account or organization.
<img width="750" alt="Install App" src="https://user-images.githubusercontent.com/230145/78968806-72100f80-7b40-11ea-810d-2bd3261e9d40.png">
When the installation is complete, you will be taken to a URL in one of the following formats, the last number of the URL will be used as the Installation ID later (For example, if the URL ends in `settings/installations/12345`, then the Installation ID is `12345`).
- `https://github.com/settings/installations/${INSTALLATION_ID}`
- `https://github.com/organizations/eventreactor/settings/installations/${INSTALLATION_ID}`
Finally, register the App ID (`APP_ID`), Installation ID (`INSTALLATION_ID`), and downloaded private key file (`PRIVATE_KEY_FILE_PATH`) to Kubernetes as Secret.
```
$ kubectl create secret generic controller-manager --from-literal=github_token=${GITHUB_TOKEN} -n actions-runner-system
$ kubectl create secret generic controller-manager \
-n actions-runner-system \
--from-literal=github_app_id=${APP_ID} \
--from-literal=github_app_installation_id=${INSTALLATION_ID} \
--from-file=github_app_private_key=${PRIVATE_KEY_FILE_PATH}
```
### Using Personal Access Token
From an account that has `admin` privileges for the repository, create a [personal access token](https://github.com/settings/tokens) with `repo` scope. This token is used to register a self-hosted runner by *actions-runner-controller*.
Self-hosted runners in GitHub can either be connected to a single repository, or to a GitHub organization (so they are available to all repositories in the organization). This token is used to register a self-hosted runner by *actions-runner-controller*.
For adding a runner to a repository, the token should have `repo` scope. If the runner should be added to an organization, the token should have `admin:org` scope. Note that to use a Personal Access Token, you must issue the token with an account that has `admin` privileges (on the repository and/or the organization).
Open the Create Token page from the following link, grant the `repo` and/or `admin:org` scope, and press the "Generate Token" button at the bottom of the page to create the token.
- [Create personal access token](https://github.com/settings/tokens/new)
Register the created token (`GITHUB_TOKEN`) as a Kubernetes secret.
```
$ kubectl create secret generic controller-manager \
-n actions-runner-system \
--from-literal=github_token=${GITHUB_TOKEN}
```
## Usage
There's generally two ways to use this controller:
There are two ways to use this controller:
- Manage runners one by one with `Runner`
- Manage a set of runners with `RunnerDeployment`
- Manage runners one by one with `Runner`.
- Manage a set of runners with `RunnerDeployment`.
### Runners
### Repository runners
To launch a single 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.
To launch a single 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.
```
# runner.yaml
@@ -67,15 +131,31 @@ NAME READY STATUS RESTARTS AGE
example-runner 2/2 Running 0 1m
```
The runner you created has been registerd to your repository.
The runner you created has been registered 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 [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.
### Organization Runners
To add the runner to an organization, you only need to replace the `repository` field with `organization`, so the runner will register itself to the organization.
```
# runner.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: Runner
metadata:
name: example-org-runner
spec:
organization: your-organization-name
```
Now you can see the runner on the organization level (if you have organization owner permissions).
### RunnerDeployments
There's also `RunnerReplicaSet` and `RunnerDeployment` that corresponds to `ReplicaSet` and `Deployment` but for `Runner`.
There are `RunnerReplicaSet` and `RunnerDeployment` that corresponds to `ReplicaSet` and `Deployment` but for `Runner`.
You usually need only `RunnerDeployment` rather than `RunnerReplicaSet` as the former is for managing the latter.
@@ -100,12 +180,125 @@ $ kubectl apply -f runner.yaml
runnerdeployment.actions.summerwind.dev/example-runnerdeploy created
```
You can see that 2 runners has been created as specified by `replicas: 2`:
You can see that 2 runners have been created as specified by `replicas: 2`:
```
$ kubectl get runners
NAME REPOSITORY STATUS
NAME REPOSITORY STATUS
example-runnerdeploy2475h595fr mumoshu/actions-runner-controller-ci Running
example-runnerdeploy2475ht2qbr mumoshu/actions-runner-controller-ci Running
```
## Additional tweaks
You can pass details through the spec selector. Here's an eg. of what you may like to do:
```yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: actions-runner
namespace: default
spec:
replicas: 2
template:
spec:
nodeSelector:
node-role.kubernetes.io/test: ""
tolerations:
- effect: NoSchedule
key: node-role.kubernetes.io/test
operator: Exists
repository: mumoshu/actions-runner-controller-ci
ImagePullPolicy: Always
image: custom-image/actions-runner:latest
resources:
limits:
cpu: "4.0"
memory: "8Gi"
requests:
cpu: "2.0"
memory: "4Gi"
sidecarContainers:
- name: mysql
image: mysql:5.7
env:
- name: MYSQL_ROOT_PASSWORD
value: abcd1234
securityContext:
runAsUser: 0
```
## Runner labels
To run a workflow job on a self-hosted runner, you can use the following syntax in your workflow:
```yaml
jobs:
release:
runs-on: self-hosted
```
When you have multiple kinds of self-hosted runners, you can distinguish between them using labels. In order to do so, you can specify one or more labels in your `Runner` or `RunnerDeployment` spec.
```yaml
# runnerdeployment.yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: RunnerDeployment
metadata:
name: custom-runner
spec:
replicas: 1
template:
spec:
repository: summerwind/actions-runner-controller
labels:
- custom-runner
```
Once this spec is applied, you can observe the labels for your runner from the repository or organization in the GitHub settings page for the repository or organization. You can now select a specific runner from your workflow by using the label in `runs-on`:
```yaml
jobs:
release:
runs-on: custom-runner
```
Note that if you specify `self-hosted` in your worlflow, then this will run your job on _any_ self-hosted runner, regardless of the labels that they have.
## Softeware installed in the runner image
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
The container image is based on Ubuntu 18.04, but it does not contain all of the software installed on the GitHub runners. It contains the following subset of packages from the GitHub runners:
* Basic CLI packages
* git (2.26)
* docker
* build-essentials
The virtual environments from GitHub contain a lot more software packages (different versions of Java, Node.js, Golang, .NET, etc) which are not provided in the runner image. Most of these have dedicated setup actions which allow the tools to be installed on-demand in a workflow, for example: `actions/setup-java` or `actions/setup-node`
If there is a need to include packages in the runner image for which there is no setup action, then this can be achieved by building a custom container image for the runner. The easiest way is to start with the `summerwind/actions-runner` image and installing the extra dependencies directly in the docker image:
```yaml
FROM summerwind/actions-runner:v2.169.1
RUN sudo apt update -y \
&& apt install YOUR_PACKAGE
&& rm -rf /var/lib/apt/lists/*
```
You can then configure the runner to use a custom docker image by configuring the `image` field of a `Runner` or `RunnerDeployment`:
```yaml
apiVersion: actions.summerwind.dev/v1alpha1
kind: Runner
metadata:
name: custom-runner
spec:
repository: summerwind/actions-runner-controller
image: YOUR_CUSTOM_DOCKER_IMAGE
```

View File

@@ -17,15 +17,24 @@ limitations under the License.
package v1alpha1
import (
"errors"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
// RunnerSpec defines the desired state of Runner
type RunnerSpec struct {
// +kubebuilder:validation:MinLength=3
// +optional
// +kubebuilder:validation:Pattern=`^[^/]+$`
Organization string `json:"organization,omitempty"`
// +optional
// +kubebuilder:validation:Pattern=`^[^/]+/[^/]+$`
Repository string `json:"repository"`
Repository string `json:"repository,omitempty"`
// +optional
Labels []string `json:"labels,omitempty"`
// +optional
Containers []corev1.Container `json:"containers,omitempty"`
@@ -68,6 +77,19 @@ type RunnerSpec struct {
TerminationGracePeriodSeconds *int64 `json:"terminationGracePeriodSeconds,omitempty"`
}
// ValidateRepository validates repository field.
func (rs *RunnerSpec) ValidateRepository() error {
// Organization and repository are both exclusive.
if len(rs.Organization) == 0 && len(rs.Repository) == 0 {
return errors.New("Spec needs organization or repository")
}
if len(rs.Organization) > 0 && len(rs.Repository) > 0 {
return errors.New("Spec cannot have both organization and repository")
}
return nil
}
// RunnerStatus defines the observed state of Runner
type RunnerStatus struct {
Registration RunnerStatusRegistration `json:"registration"`
@@ -76,15 +98,20 @@ type RunnerStatus struct {
Message string `json:"message"`
}
// RunnerStatusRegistration contains runner registration status
type RunnerStatusRegistration struct {
Repository string `json:"repository"`
Token string `json:"token"`
ExpiresAt metav1.Time `json:"expiresAt"`
Organization string `json:"organization,omitempty"`
Repository string `json:"repository,omitempty"`
Labels []string `json:"labels,omitempty"`
Token string `json:"token"`
ExpiresAt metav1.Time `json:"expiresAt"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:printcolumn:JSONPath=".spec.organization",name=Organization,type=string
// +kubebuilder:printcolumn:JSONPath=".spec.repository",name=Repository,type=string
// +kubebuilder:printcolumn:JSONPath=".spec.labels",name=Labels,type=string
// +kubebuilder:printcolumn:JSONPath=".status.phase",name=Status,type=string
// Runner is the Schema for the runners API

View File

@@ -0,0 +1,84 @@
/*
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 v1alpha1
import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/runtime/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
)
// log is for logging in this package.
var runnerLog = logf.Log.WithName("runner-resource")
func (r *Runner) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
Complete()
}
// +kubebuilder:webhook:path=/mutate-actions-summerwind-dev-v1alpha1-runner,verbs=create;update,mutating=true,failurePolicy=fail,groups=actions.summerwind.dev,resources=runners,versions=v1alpha1,name=mutate.runner.actions.summerwind.dev
var _ webhook.Defaulter = &Runner{}
// Default implements webhook.Defaulter so a webhook will be registered for the type
func (r *Runner) Default() {
// Nothing to do.
}
// +kubebuilder:webhook:path=/validate-actions-summerwind-dev-v1alpha1-runner,verbs=create;update,mutating=false,failurePolicy=fail,groups=actions.summerwind.dev,resources=runners,versions=v1alpha1,name=validate.runner.actions.summerwind.dev
var _ webhook.Validator = &Runner{}
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *Runner) ValidateCreate() error {
runnerLog.Info("validate resource to be created", "name", r.Name)
return r.Validate()
}
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (r *Runner) ValidateUpdate(old runtime.Object) error {
runnerLog.Info("validate resource to be updated", "name", r.Name)
return r.Validate()
}
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
func (r *Runner) ValidateDelete() error {
return nil
}
// Validate validates resource spec.
func (r *Runner) Validate() error {
var (
errList field.ErrorList
err error
)
err = r.Spec.ValidateRepository()
if err != nil {
errList = append(errList, field.Invalid(field.NewPath("spec", "repository"), r.Spec.Repository, err.Error()))
}
if len(errList) > 0 {
return apierrors.NewInvalid(r.GroupVersionKind().GroupKind(), r.Name, errList)
}
return nil
}

View File

@@ -0,0 +1,84 @@
/*
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 v1alpha1
import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/runtime/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
)
// log is for logging in this package.
var runenrDeploymentLog = logf.Log.WithName("runnerdeployment-resource")
func (r *RunnerDeployment) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
Complete()
}
// +kubebuilder:webhook:path=/mutate-actions-summerwind-dev-v1alpha1-runnerdeployment,verbs=create;update,mutating=true,failurePolicy=fail,groups=actions.summerwind.dev,resources=runnerdeployments,versions=v1alpha1,name=mutate.runnerdeployment.actions.summerwind.dev
var _ webhook.Defaulter = &RunnerDeployment{}
// Default implements webhook.Defaulter so a webhook will be registered for the type
func (r *RunnerDeployment) Default() {
// Nothing to do.
}
// +kubebuilder:webhook:path=/validate-actions-summerwind-dev-v1alpha1-runnerdeployment,verbs=create;update,mutating=false,failurePolicy=fail,groups=actions.summerwind.dev,resources=runnerdeployments,versions=v1alpha1,name=validate.runnerdeployment.actions.summerwind.dev
var _ webhook.Validator = &RunnerDeployment{}
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *RunnerDeployment) ValidateCreate() error {
runenrDeploymentLog.Info("validate resource to be created", "name", r.Name)
return r.Validate()
}
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (r *RunnerDeployment) ValidateUpdate(old runtime.Object) error {
runenrDeploymentLog.Info("validate resource to be updated", "name", r.Name)
return r.Validate()
}
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
func (r *RunnerDeployment) ValidateDelete() error {
return nil
}
// Validate validates resource spec.
func (r *RunnerDeployment) Validate() error {
var (
errList field.ErrorList
err error
)
err = r.Spec.Template.Spec.ValidateRepository()
if err != nil {
errList = append(errList, field.Invalid(field.NewPath("spec", "template", "spec", "repository"), r.Spec.Template.Spec.Repository, err.Error()))
}
if len(errList) > 0 {
return apierrors.NewInvalid(r.GroupVersionKind().GroupKind(), r.Name, errList)
}
return nil
}

View File

@@ -0,0 +1,84 @@
/*
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 v1alpha1
import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/validation/field"
ctrl "sigs.k8s.io/controller-runtime"
logf "sigs.k8s.io/controller-runtime/pkg/runtime/log"
"sigs.k8s.io/controller-runtime/pkg/webhook"
)
// log is for logging in this package.
var runnerReplicaSetLog = logf.Log.WithName("runnerreplicaset-resource")
func (r *RunnerReplicaSet) SetupWebhookWithManager(mgr ctrl.Manager) error {
return ctrl.NewWebhookManagedBy(mgr).
For(r).
Complete()
}
// +kubebuilder:webhook:path=/mutate-actions-summerwind-dev-v1alpha1-runnerreplicaset,verbs=create;update,mutating=true,failurePolicy=fail,groups=actions.summerwind.dev,resources=runnerreplicasets,versions=v1alpha1,name=mutate.runnerreplicaset.actions.summerwind.dev
var _ webhook.Defaulter = &RunnerReplicaSet{}
// Default implements webhook.Defaulter so a webhook will be registered for the type
func (r *RunnerReplicaSet) Default() {
// Nothing to do.
}
// +kubebuilder:webhook:path=/validate-actions-summerwind-dev-v1alpha1-runnerreplicaset,verbs=create;update,mutating=false,failurePolicy=fail,groups=actions.summerwind.dev,resources=runnerreplicasets,versions=v1alpha1,name=validate.runnerreplicaset.actions.summerwind.dev
var _ webhook.Validator = &RunnerReplicaSet{}
// ValidateCreate implements webhook.Validator so a webhook will be registered for the type
func (r *RunnerReplicaSet) ValidateCreate() error {
runnerReplicaSetLog.Info("validate resource to be created", "name", r.Name)
return r.Validate()
}
// ValidateUpdate implements webhook.Validator so a webhook will be registered for the type
func (r *RunnerReplicaSet) ValidateUpdate(old runtime.Object) error {
runnerReplicaSetLog.Info("validate resource to be updated", "name", r.Name)
return r.Validate()
}
// ValidateDelete implements webhook.Validator so a webhook will be registered for the type
func (r *RunnerReplicaSet) ValidateDelete() error {
return nil
}
// Validate validates resource spec.
func (r *RunnerReplicaSet) Validate() error {
var (
errList field.ErrorList
err error
)
err = r.Spec.Template.Spec.ValidateRepository()
if err != nil {
errList = append(errList, field.Invalid(field.NewPath("spec", "template", "spec", "repository"), r.Spec.Template.Spec.Repository, err.Error()))
}
if len(errList) > 0 {
return apierrors.NewInvalid(r.GroupVersionKind().GroupKind(), r.Name, errList)
}
return nil
}

View File

@@ -22,7 +22,7 @@ package v1alpha1
import (
"k8s.io/api/core/v1"
runtime "k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime"
)
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
@@ -277,6 +277,11 @@ func (in *RunnerReplicaSetStatus) DeepCopy() *RunnerReplicaSetStatus {
// 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.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make([]string, len(*in))
copy(*out, *in)
}
if in.Containers != nil {
in, out := &in.Containers, &out.Containers
*out = make([]v1.Container, len(*in))
@@ -404,6 +409,11 @@ func (in *RunnerStatus) DeepCopy() *RunnerStatus {
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *RunnerStatusRegistration) DeepCopyInto(out *RunnerStatusRegistration) {
*out = *in
if in.Labels != nil {
in, out := &in.Labels, &out.Labels
*out = make([]string, len(*in))
copy(*out, *in)
}
in.ExpiresAt.DeepCopyInto(&out.ExpiresAt)
}

View File

@@ -4111,12 +4111,18 @@ spec:
- name
type: object
type: array
labels:
items:
type: string
type: array
nodeSelector:
additionalProperties:
type: string
type: object
organization:
pattern: ^[^/]+$
type: string
repository:
minLength: 3
pattern: ^[^/]+/[^/]+$
type: string
resources:
@@ -6708,8 +6714,6 @@ spec:
- name
type: object
type: array
required:
- repository
type: object
type: object
required:

View File

@@ -4111,12 +4111,18 @@ spec:
- name
type: object
type: array
labels:
items:
type: string
type: array
nodeSelector:
additionalProperties:
type: string
type: object
organization:
pattern: ^[^/]+$
type: string
repository:
minLength: 3
pattern: ^[^/]+/[^/]+$
type: string
resources:
@@ -6708,8 +6714,6 @@ spec:
- name
type: object
type: array
required:
- repository
type: object
type: object
required:

View File

@@ -9,9 +9,15 @@ metadata:
name: runners.actions.summerwind.dev
spec:
additionalPrinterColumns:
- JSONPath: .spec.organization
name: Organization
type: string
- JSONPath: .spec.repository
name: Repository
type: string
- JSONPath: .spec.labels
name: Labels
type: string
- JSONPath: .status.phase
name: Status
type: string
@@ -3854,12 +3860,18 @@ spec:
- name
type: object
type: array
labels:
items:
type: string
type: array
nodeSelector:
additionalProperties:
type: string
type: object
organization:
pattern: ^[^/]+$
type: string
repository:
minLength: 3
pattern: ^[^/]+/[^/]+$
type: string
resources:
@@ -6292,8 +6304,6 @@ spec:
- name
type: object
type: array
required:
- repository
type: object
status:
description: RunnerStatus defines the observed state of Runner
@@ -6305,17 +6315,23 @@ spec:
reason:
type: string
registration:
description: RunnerStatusRegistration contains runner registration status
properties:
expiresAt:
format: date-time
type: string
labels:
items:
type: string
type: array
organization:
type: string
repository:
type: string
token:
type: string
required:
- expiresAt
- repository
- token
type: object
required:

View File

@@ -17,9 +17,9 @@ bases:
- ../rbac
- ../manager
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in crd/kustomization.yaml
#- ../webhook
- ../webhook
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required.
#- ../certmanager
- ../certmanager
# [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'.
#- ../prometheus
@@ -36,39 +36,39 @@ patchesStrategicMerge:
#- manager_prometheus_metrics_patch.yaml
# [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in crd/kustomization.yaml
#- manager_webhook_patch.yaml
- manager_webhook_patch.yaml
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'.
# Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks.
# 'CERTMANAGER' needs to be enabled to use ca injection
#- webhookcainjection_patch.yaml
- webhookcainjection_patch.yaml
# the following config is for teaching kustomize how to do var substitution
vars:
# [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix.
#- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR
# objref:
# kind: Certificate
# group: cert-manager.io
# version: v1alpha2
# name: serving-cert # this name should match the one in certificate.yaml
# fieldref:
# fieldpath: metadata.namespace
#- name: CERTIFICATE_NAME
# objref:
# kind: Certificate
# group: cert-manager.io
# version: v1alpha2
# name: serving-cert # this name should match the one in certificate.yaml
#- name: SERVICE_NAMESPACE # namespace of the service
# objref:
# kind: Service
# version: v1
# name: webhook-service
# fieldref:
# fieldpath: metadata.namespace
#- name: SERVICE_NAME
# objref:
# kind: Service
# version: v1
# name: webhook-service
- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR
objref:
kind: Certificate
group: cert-manager.io
version: v1alpha2
name: serving-cert # this name should match the one in certificate.yaml
fieldref:
fieldpath: metadata.namespace
- name: CERTIFICATE_NAME
objref:
kind: Certificate
group: cert-manager.io
version: v1alpha2
name: serving-cert # this name should match the one in certificate.yaml
- name: SERVICE_NAMESPACE # namespace of the service
objref:
kind: Service
version: v1
name: webhook-service
fieldref:
fieldpath: metadata.namespace
- name: SERVICE_NAME
objref:
kind: Service
version: v1
name: webhook-service

View File

@@ -35,6 +35,25 @@ spec:
secretKeyRef:
name: controller-manager
key: github_token
optional: true
- name: GITHUB_APP_ID
valueFrom:
secretKeyRef:
name: controller-manager
key: github_app_id
optional: true
- name: GITHUB_APP_INSTALLATION_ID
valueFrom:
secretKeyRef:
name: controller-manager
key: github_app_installation_id
optional: true
- name: GITHUB_APP_PRIVATE_KEY
value: /etc/actions-runner-controller/github_app_private_key
volumeMounts:
- name: controller-manager
mountPath: "/etc/actions-runner-controller"
readOnly: true
resources:
limits:
cpu: 100m
@@ -42,4 +61,8 @@ spec:
requests:
cpu: 100m
memory: 20Mi
volumes:
- name: controller-manager
secret:
secretName: controller-manager
terminationGracePeriodSeconds: 10

View File

@@ -66,6 +66,13 @@ rules:
- get
- patch
- update
- apiGroups:
- ""
resources:
- events
verbs:
- create
- patch
- apiGroups:
- ""
resources:

View File

@@ -0,0 +1,124 @@
---
apiVersion: admissionregistration.k8s.io/v1beta1
kind: MutatingWebhookConfiguration
metadata:
creationTimestamp: null
name: mutating-webhook-configuration
webhooks:
- clientConfig:
caBundle: Cg==
service:
name: webhook-service
namespace: system
path: /mutate-actions-summerwind-dev-v1alpha1-runner
failurePolicy: Fail
name: mutate.runner.actions.summerwind.dev
rules:
- apiGroups:
- actions.summerwind.dev
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- runners
- clientConfig:
caBundle: Cg==
service:
name: webhook-service
namespace: system
path: /mutate-actions-summerwind-dev-v1alpha1-runnerdeployment
failurePolicy: Fail
name: mutate.runnerdeployment.actions.summerwind.dev
rules:
- apiGroups:
- actions.summerwind.dev
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- runnerdeployments
- clientConfig:
caBundle: Cg==
service:
name: webhook-service
namespace: system
path: /mutate-actions-summerwind-dev-v1alpha1-runnerreplicaset
failurePolicy: Fail
name: mutate.runnerreplicaset.actions.summerwind.dev
rules:
- apiGroups:
- actions.summerwind.dev
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- runnerreplicasets
---
apiVersion: admissionregistration.k8s.io/v1beta1
kind: ValidatingWebhookConfiguration
metadata:
creationTimestamp: null
name: validating-webhook-configuration
webhooks:
- clientConfig:
caBundle: Cg==
service:
name: webhook-service
namespace: system
path: /validate-actions-summerwind-dev-v1alpha1-runner
failurePolicy: Fail
name: validate.runner.actions.summerwind.dev
rules:
- apiGroups:
- actions.summerwind.dev
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- runners
- clientConfig:
caBundle: Cg==
service:
name: webhook-service
namespace: system
path: /validate-actions-summerwind-dev-v1alpha1-runnerdeployment
failurePolicy: Fail
name: validate.runnerdeployment.actions.summerwind.dev
rules:
- apiGroups:
- actions.summerwind.dev
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- runnerdeployments
- clientConfig:
caBundle: Cg==
service:
name: webhook-service
namespace: system
path: /validate-actions-summerwind-dev-v1alpha1-runnerreplicaset
failurePolicy: Fail
name: validate.runnerreplicaset.actions.summerwind.dev
rules:
- apiGroups:
- actions.summerwind.dev
apiVersions:
- v1alpha1
operations:
- CREATE
- UPDATE
resources:
- runnerreplicasets

View File

@@ -20,10 +20,9 @@ import (
"context"
"fmt"
"reflect"
"time"
"strings"
"github.com/go-logr/logr"
"github.com/google/go-github/v29/github"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/client-go/tools/record"
@@ -34,6 +33,7 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/summerwind/actions-runner-controller/api/v1alpha1"
"github.com/summerwind/actions-runner-controller/github"
)
const (
@@ -41,23 +41,6 @@ const (
finalizerName = "runner.actions.summerwind.dev"
)
type GitHubRunnerList struct {
TotalCount int `json:"total_count"`
Runners []GitHubRunner `json:"runners,omitempty"`
}
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"`
}
// RunnerReconciler reconciles a Runner object
type RunnerReconciler struct {
client.Client
@@ -72,6 +55,7 @@ type RunnerReconciler struct {
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=runners,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=runners/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
ctx := context.Background()
@@ -82,6 +66,12 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
return ctrl.Result{}, client.IgnoreNotFound(err)
}
err := runner.Validate()
if err != nil {
log.Info("Failed to validate runner spec", "error", err.Error())
return ctrl.Result{}, nil
}
if runner.ObjectMeta.DeletionTimestamp.IsZero() {
finalizers, added := addFinalizer(runner.ObjectMeta.Finalizers)
@@ -100,14 +90,18 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
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 len(runner.Status.Registration.Token) > 0 {
ok, err := r.unregisterRunner(ctx, runner.Spec.Organization, 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")
if !ok {
log.V(1).Info("Runner no longer exists on GitHub")
}
} else {
log.V(1).Info("Runner was never registered on GitHub")
}
newRunner := runner.DeepCopy()
@@ -118,14 +112,14 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
return ctrl.Result{}, err
}
log.Info("Removed runner from GitHub", "repository", runner.Spec.Repository)
log.Info("Removed runner from GitHub", "repository", runner.Spec.Repository, "organization", runner.Spec.Organization)
}
return ctrl.Result{}, nil
}
if !runner.IsRegisterable() {
reg, err := r.newRegistration(ctx, runner.Spec.Repository)
rt, err := r.GitHubClient.GetRegistrationToken(ctx, runner.Spec.Organization, runner.Spec.Repository, runner.Name)
if err != nil {
r.Recorder.Event(&runner, corev1.EventTypeWarning, "FailedUpdateRegistrationToken", "Updating registration token failed")
log.Error(err, "Failed to get new registration token")
@@ -133,7 +127,13 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
}
updated := runner.DeepCopy()
updated.Status.Registration = reg
updated.Status.Registration = v1alpha1.RunnerStatusRegistration{
Organization: runner.Spec.Organization,
Repository: runner.Spec.Repository,
Labels: runner.Spec.Labels,
Token: rt.GetToken(),
ExpiresAt: metav1.NewTime(rt.GetExpiresAt().Time),
}
if err := r.Status().Update(ctx, updated); err != nil {
log.Error(err, "Failed to update runner status")
@@ -225,109 +225,31 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
return ctrl.Result{}, nil
}
func (r *RunnerReconciler) newRegistration(ctx context.Context, repo string) (v1alpha1.RunnerStatusRegistration, error) {
var reg v1alpha1.RunnerStatusRegistration
rt, err := r.getRegistrationToken(ctx, repo)
if err != nil {
return reg, err
}
expiresAt, err := time.Parse(time.RFC3339, rt.ExpiresAt)
if err != nil {
return reg, err
}
reg.Repository = repo
reg.Token = rt.Token
reg.ExpiresAt = metav1.NewTime(expiresAt)
return reg, err
}
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 {
return regToken, err
}
res, err := r.GitHubClient.Do(ctx, req, &regToken)
if err != nil {
return regToken, err
}
if res.StatusCode != 201 {
return regToken, fmt.Errorf("unexpected status: %d", res.StatusCode)
}
return regToken, nil
}
func (r *RunnerReconciler) unregisterRunner(ctx context.Context, repo, name string) (bool, error) {
runners, err := r.listRunners(ctx, repo)
func (r *RunnerReconciler) unregisterRunner(ctx context.Context, org, repo, name string) (bool, error) {
runners, err := r.GitHubClient.ListRunners(ctx, org, repo)
if err != nil {
return false, err
}
id := 0
for _, runner := range runners.Runners {
if runner.Name == name {
id = runner.ID
id := int64(0)
for _, runner := range runners {
if runner.GetName() == name {
id = runner.GetID()
break
}
}
if id == 0 {
if id == int64(0) {
return false, nil
}
if err := r.removeRunner(ctx, repo, id); err != nil {
if err := r.GitHubClient.RemoveRunner(ctx, org, repo, id); err != nil {
return false, err
}
return true, nil
}
func (r *RunnerReconciler) listRunners(ctx context.Context, repo string) (GitHubRunnerList, error) {
runners := GitHubRunnerList{}
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
@@ -344,10 +266,18 @@ func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
Name: "RUNNER_NAME",
Value: runner.Name,
},
{
Name: "RUNNER_ORG",
Value: runner.Spec.Organization,
},
{
Name: "RUNNER_REPO",
Value: runner.Spec.Repository,
},
{
Name: "RUNNER_LABELS",
Value: strings.Join(runner.Spec.Labels, ","),
},
{
Name: "RUNNER_TOKEN",
Value: runner.Status.Registration.Token,
@@ -372,6 +302,10 @@ func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
Env: env,
EnvFrom: runner.Spec.EnvFrom,
VolumeMounts: []corev1.VolumeMount{
{
Name: "work",
MountPath: "/runner/_work",
},
{
Name: "docker",
MountPath: "/var/run",
@@ -386,6 +320,10 @@ func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
Name: "docker",
Image: r.DockerImage,
VolumeMounts: []corev1.VolumeMount{
{
Name: "work",
MountPath: "/runner/_work",
},
{
Name: "docker",
MountPath: "/var/run",
@@ -397,7 +335,13 @@ func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
},
},
Volumes: []corev1.Volume{
corev1.Volume{
{
Name: "work",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
},
{
Name: "docker",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
@@ -416,7 +360,7 @@ func (r *RunnerReconciler) newPod(runner v1alpha1.Runner) (corev1.Pod, error) {
}
if len(runner.Spec.Volumes) != 0 {
pod.Spec.Volumes = append(runner.Spec.Volumes, runner.Spec.Volumes...)
pod.Spec.Volumes = append(pod.Spec.Volumes, runner.Spec.Volumes...)
}
if len(runner.Spec.InitContainers) != 0 {
pod.Spec.InitContainers = append(pod.Spec.InitContainers, runner.Spec.InitContainers...)

View File

@@ -20,7 +20,9 @@ import (
"context"
"fmt"
"hash/fnv"
"k8s.io/apimachinery/pkg/types"
"sort"
"time"
"github.com/davecgh/go-spew/spew"
"github.com/go-logr/logr"
@@ -54,10 +56,11 @@ type RunnerDeploymentReconciler struct {
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=runnerdeployments/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=runnerreplicasets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=runnerreplicasets/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
func (r *RunnerDeploymentReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
ctx := context.Background()
log := r.Log.WithValues("runnerreplicaset", req.NamespacedName)
log := r.Log.WithValues("runnerdeployment", req.NamespacedName)
var rd v1alpha1.RunnerDeployment
if err := r.Get(ctx, req.NamespacedName, &rd); err != nil {
@@ -129,12 +132,19 @@ func (r *RunnerDeploymentReconciler) Reconcile(req ctrl.Request) (ctrl.Result, e
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
// We requeue in order to clean up old runner replica sets later.
// Otherwise, they aren't cleaned up until the next re-sync interval.
return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
}
const defaultReplicas = 1
currentDesiredReplicas := getIntOrDefault(newestSet.Spec.Replicas, defaultReplicas)
newDesiredReplicas := getIntOrDefault(desiredRS.Spec.Replicas, defaultReplicas)
// Please add more conditions that we can in-place update the newest runnerreplicaset without disruption
if newestSet.Spec.Replicas != desiredRS.Spec.Replicas {
newestSet.Spec.Replicas = desiredRS.Spec.Replicas
if currentDesiredReplicas != newDesiredReplicas {
newestSet.Spec.Replicas = &newDesiredReplicas
if err := r.Client.Update(ctx, newestSet); err != nil {
log.Error(err, "Failed to update runnerreplicaset resource")
@@ -142,25 +152,49 @@ func (r *RunnerDeploymentReconciler) Reconcile(req ctrl.Request) (ctrl.Result, e
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
return ctrl.Result{}, err
}
for i := range oldSets {
rs := oldSets[i]
// Do we old runner replica sets that should eventually deleted?
if len(oldSets) > 0 {
readyReplicas := newestSet.Status.ReadyReplicas
if err := r.Client.Delete(ctx, &rs); err != nil {
log.Error(err, "Failed to delete runner resource")
if readyReplicas < currentDesiredReplicas {
log.WithValues("runnerreplicaset", types.NamespacedName{
Namespace: newestSet.Namespace,
Name: newestSet.Name,
}).
Info("Waiting until the newest runner replica set to be 100% available")
return ctrl.Result{}, err
return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
}
r.Recorder.Event(&rd, corev1.EventTypeNormal, "RunnerReplicaSetDeleted", fmt.Sprintf("Deleted runnerreplicaset '%s'", rs.Name))
log.Info("Deleted runnerreplicaset", "runnerdeployment", rd.ObjectMeta.Name, "runnerreplicaset", rs.Name)
for i := range oldSets {
rs := oldSets[i]
if err := r.Client.Delete(ctx, &rs); err != nil {
log.Error(err, "Failed to delete runner resource")
return ctrl.Result{}, err
}
r.Recorder.Event(&rd, corev1.EventTypeNormal, "RunnerReplicaSetDeleted", fmt.Sprintf("Deleted runnerreplicaset '%s'", rs.Name))
log.Info("Deleted runnerreplicaset", "runnerdeployment", rd.ObjectMeta.Name, "runnerreplicaset", rs.Name)
}
}
return ctrl.Result{}, nil
}
func getIntOrDefault(p *int, d int) int {
if p == nil {
return d
}
return *p
}
func getTemplateHash(rs *v1alpha1.RunnerReplicaSet) (string, bool) {
hash, ok := rs.Labels[LabelKeyRunnerTemplateHash]

View File

@@ -45,6 +45,7 @@ type RunnerReplicaSetReconciler struct {
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=runnerreplicasets/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=runners,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=actions.summerwind.dev,resources=runners/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
func (r *RunnerReplicaSetReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) {
ctx := context.Background()

123
github/fake/fake.go Normal file
View File

@@ -0,0 +1,123 @@
package fake
import (
"fmt"
"net/http"
"net/http/httptest"
"time"
)
const (
RegistrationToken = "fake-registration-token"
RunnersListBody = `
{
"total_count": 2,
"runners": [
{"id": 1, "name": "test1", "os": "linux", "status": "online"},
{"id": 2, "name": "test2", "os": "linux", "status": "offline"}
]
}
`
)
type handler struct {
Status int
Body string
}
func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(h.Status)
fmt.Fprintf(w, h.Body)
}
// NewServer creates a fake server for running unit tests
func NewServer() *httptest.Server {
routes := map[string]handler{
// For CreateRegistrationToken
"/repos/test/valid/actions/runners/registration-token": handler{
Status: http.StatusCreated,
Body: fmt.Sprintf("{\"token\": \"%s\", \"expires_at\": \"%s\"}", RegistrationToken, time.Now().Add(time.Hour*1).Format(time.RFC3339)),
},
"/repos/test/invalid/actions/runners/registration-token": handler{
Status: http.StatusOK,
Body: fmt.Sprintf("{\"token\": \"%s\", \"expires_at\": \"%s\"}", RegistrationToken, time.Now().Add(time.Hour*1).Format(time.RFC3339)),
},
"/repos/test/error/actions/runners/registration-token": handler{
Status: http.StatusBadRequest,
Body: "",
},
"/orgs/test/actions/runners/registration-token": handler{
Status: http.StatusCreated,
Body: fmt.Sprintf("{\"token\": \"%s\", \"expires_at\": \"%s\"}", RegistrationToken, time.Now().Add(time.Hour*1).Format(time.RFC3339)),
},
"/orgs/invalid/actions/runners/registration-token": handler{
Status: http.StatusOK,
Body: fmt.Sprintf("{\"token\": \"%s\", \"expires_at\": \"%s\"}", RegistrationToken, time.Now().Add(time.Hour*1).Format(time.RFC3339)),
},
"/orgs/error/actions/runners/registration-token": handler{
Status: http.StatusBadRequest,
Body: "",
},
// For ListRunners
"/repos/test/valid/actions/runners": handler{
Status: http.StatusOK,
Body: RunnersListBody,
},
"/repos/test/invalid/actions/runners": handler{
Status: http.StatusNoContent,
Body: "",
},
"/repos/test/error/actions/runners": handler{
Status: http.StatusBadRequest,
Body: "",
},
"/orgs/test/actions/runners": handler{
Status: http.StatusOK,
Body: RunnersListBody,
},
"/orgs/invalid/actions/runners": handler{
Status: http.StatusNoContent,
Body: "",
},
"/orgs/error/actions/runners": handler{
Status: http.StatusBadRequest,
Body: "",
},
// For RemoveRunner
"/repos/test/valid/actions/runners/1": handler{
Status: http.StatusNoContent,
Body: "",
},
"/repos/test/invalid/actions/runners/1": handler{
Status: http.StatusOK,
Body: "",
},
"/repos/test/error/actions/runners/1": handler{
Status: http.StatusBadRequest,
Body: "",
},
"/orgs/test/actions/runners/1": handler{
Status: http.StatusNoContent,
Body: "",
},
"/orgs/invalid/actions/runners/1": handler{
Status: http.StatusOK,
Body: "",
},
"/orgs/error/actions/runners/1": handler{
Status: http.StatusBadRequest,
Body: "",
},
}
mux := http.NewServeMux()
for path, handler := range routes {
h := handler
mux.Handle(path, &h)
}
return httptest.NewServer(mux)
}

199
github/github.go Normal file
View File

@@ -0,0 +1,199 @@
package github
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/bradleyfalzon/ghinstallation"
"github.com/google/go-github/v31/github"
"golang.org/x/oauth2"
)
// Client wraps GitHub client with some additional
type Client struct {
*github.Client
regTokens map[string]*github.RegistrationToken
mu sync.Mutex
}
// NewClient returns a client authenticated as a GitHub App.
func NewClient(appID, installationID int64, privateKeyPath string) (*Client, error) {
tr, err := ghinstallation.NewKeyFromFile(http.DefaultTransport, appID, installationID, privateKeyPath)
if err != nil {
return nil, fmt.Errorf("authentication failed: %v", err)
}
return &Client{
Client: github.NewClient(&http.Client{Transport: tr}),
regTokens: map[string]*github.RegistrationToken{},
mu: sync.Mutex{},
}, nil
}
// NewClientWithAccessToken returns a client authenticated with personal access token.
func NewClientWithAccessToken(token string) (*Client, error) {
tc := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: token},
))
return &Client{
Client: github.NewClient(tc),
regTokens: map[string]*github.RegistrationToken{},
mu: sync.Mutex{},
}, nil
}
// GetRegistrationToken returns a registration token tied with the name of repository and runner.
func (c *Client) GetRegistrationToken(ctx context.Context, org, repo, name string) (*github.RegistrationToken, error) {
c.mu.Lock()
defer c.mu.Unlock()
key := getRegistrationKey(org, repo)
rt, ok := c.regTokens[key]
if ok && rt.GetExpiresAt().After(time.Now().Add(-10*time.Minute)) {
return rt, nil
}
owner, repo, err := getOwnerAndRepo(org, repo)
if err != nil {
return rt, err
}
rt, res, err := c.createRegistrationToken(ctx, owner, repo)
if err != nil {
return nil, fmt.Errorf("failed to create registration token: %v", err)
}
if res.StatusCode != 201 {
return nil, fmt.Errorf("unexpected status: %d", res.StatusCode)
}
c.regTokens[key] = rt
go func() {
c.cleanup()
}()
return rt, nil
}
// RemoveRunner removes a runner with specified runner ID from repocitory.
func (c *Client) RemoveRunner(ctx context.Context, org, repo string, runnerID int64) error {
owner, repo, err := getOwnerAndRepo(org, repo)
if err != nil {
return err
}
res, err := c.removeRunner(ctx, owner, repo, runnerID)
if err != nil {
return fmt.Errorf("failed to remove runner: %v", err)
}
if res.StatusCode != 204 {
return fmt.Errorf("unexpected status: %d", res.StatusCode)
}
return nil
}
// ListRunners returns a list of runners of specified owner/repository name.
func (c *Client) ListRunners(ctx context.Context, org, repo string) ([]*github.Runner, error) {
owner, repo, err := getOwnerAndRepo(org, repo)
if err != nil {
return nil, err
}
var runners []*github.Runner
opts := github.ListOptions{PerPage: 10}
for {
list, res, err := c.listRunners(ctx, owner, repo, &opts)
if err != nil {
return runners, fmt.Errorf("failed to remove runner: %v", err)
}
runners = append(runners, list.Runners...)
if res.NextPage == 0 {
break
}
opts.Page = res.NextPage
}
return runners, nil
}
// cleanup removes expired registration tokens.
func (c *Client) cleanup() {
c.mu.Lock()
defer c.mu.Unlock()
for key, rt := range c.regTokens {
if rt.GetExpiresAt().Before(time.Now()) {
delete(c.regTokens, key)
}
}
}
// wrappers for github functions (switch between organization/repository mode)
// so the calling functions don't need to switch and their code is a bit cleaner
func (c *Client) createRegistrationToken(ctx context.Context, owner, repo string) (*github.RegistrationToken, *github.Response, error) {
if len(repo) > 0 {
return c.Client.Actions.CreateRegistrationToken(ctx, owner, repo)
} else {
return CreateOrganizationRegistrationToken(ctx, c, owner)
}
}
func (c *Client) removeRunner(ctx context.Context, owner, repo string, runnerID int64) (*github.Response, error) {
if len(repo) > 0 {
return c.Client.Actions.RemoveRunner(ctx, owner, repo, runnerID)
} else {
return RemoveOrganizationRunner(ctx, c, owner, runnerID)
}
}
func (c *Client) listRunners(ctx context.Context, owner, repo string, opts *github.ListOptions) (*github.Runners, *github.Response, error) {
if len(repo) > 0 {
return c.Client.Actions.ListRunners(ctx, owner, repo, opts)
} else {
return ListOrganizationRunners(ctx, c, owner, opts)
}
}
// Validates owner and repo arguments. Both are optional, but at least one should be specified
func getOwnerAndRepo(org, repo string) (string, string, error) {
if len(repo) > 0 {
return splitOwnerAndRepo(repo)
}
if len(org) > 0 {
return org, "", nil
}
return "", "", fmt.Errorf("organization and repository are both empty")
}
func getRegistrationKey(org, repo string) string {
if len(org) > 0 {
return org
} else {
return repo
}
}
func splitOwnerAndRepo(repo string) (string, string, error) {
chunk := strings.Split(repo, "/")
if len(chunk) != 2 {
return "", "", fmt.Errorf("invalid repository name: '%s'", repo)
}
return chunk[0], chunk[1], nil
}

95
github/github_beta.go Normal file
View File

@@ -0,0 +1,95 @@
package github
// this contains BETA API clients, that are currently not (yet) in go-github
// once these functions have been added there, they can be removed from here
// code was reused from https://github.com/google/go-github
import (
"context"
"fmt"
"net/url"
"reflect"
"github.com/google/go-github/v31/github"
"github.com/google/go-querystring/query"
)
// CreateOrganizationRegistrationToken creates a token that can be used to add a self-hosted runner on an organization.
//
// GitHub API docs: https://developer.github.com/v3/actions/self-hosted-runners/#create-a-registration-token-for-an-organization
func CreateOrganizationRegistrationToken(ctx context.Context, client *Client, owner string) (*github.RegistrationToken, *github.Response, error) {
u := fmt.Sprintf("orgs/%v/actions/runners/registration-token", owner)
req, err := client.NewRequest("POST", u, nil)
if err != nil {
return nil, nil, err
}
registrationToken := new(github.RegistrationToken)
resp, err := client.Do(ctx, req, registrationToken)
if err != nil {
return nil, resp, err
}
return registrationToken, resp, nil
}
// ListOrganizationRunners lists all the self-hosted runners for an organization.
//
// GitHub API docs: https://developer.github.com/v3/actions/self-hosted-runners/#list-self-hosted-runners-for-an-organization
func ListOrganizationRunners(ctx context.Context, client *Client, owner string, opts *github.ListOptions) (*github.Runners, *github.Response, error) {
u := fmt.Sprintf("orgs/%v/actions/runners", owner)
u, err := addOptions(u, opts)
if err != nil {
return nil, nil, err
}
req, err := client.NewRequest("GET", u, nil)
if err != nil {
return nil, nil, err
}
runners := &github.Runners{}
resp, err := client.Do(ctx, req, &runners)
if err != nil {
return nil, resp, err
}
return runners, resp, nil
}
// RemoveOrganizationRunner forces the removal of a self-hosted runner in a repository using the runner id.
//
// GitHub API docs: https://developer.github.com/v3/actions/self_hosted_runners/#remove-a-self-hosted-runner
func RemoveOrganizationRunner(ctx context.Context, client *Client, owner string, runnerID int64) (*github.Response, error) {
u := fmt.Sprintf("orgs/%v/actions/runners/%v", owner, runnerID)
req, err := client.NewRequest("DELETE", u, nil)
if err != nil {
return nil, err
}
return client.Do(ctx, req, nil)
}
// addOptions adds the parameters in opt as URL query parameters to s. opt
// must be a struct whose fields may contain "url" tags.
func addOptions(s string, opts interface{}) (string, error) {
v := reflect.ValueOf(opts)
if v.Kind() == reflect.Ptr && v.IsNil() {
return s, nil
}
u, err := url.Parse(s)
if err != nil {
return s, err
}
qs, err := query.Values(opts)
if err != nil {
return s, err
}
u.RawQuery = qs.Encode()
return u.String(), nil
}

136
github/github_test.go Normal file
View File

@@ -0,0 +1,136 @@
package github
import (
"context"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/google/go-github/v31/github"
"github.com/summerwind/actions-runner-controller/github/fake"
)
var server *httptest.Server
func newTestClient() *Client {
client, err := NewClientWithAccessToken("token")
if err != nil {
panic(err)
}
baseURL, err := url.Parse(server.URL + "/")
if err != nil {
panic(err)
}
client.Client.BaseURL = baseURL
return client
}
func TestMain(m *testing.M) {
server = fake.NewServer()
defer server.Close()
m.Run()
}
func TestGetRegistrationToken(t *testing.T) {
tests := []struct {
org string
repo string
token string
err bool
}{
{org: "test", repo: "valid", token: fake.RegistrationToken, err: false},
{org: "test", repo: "invalid", token: "", err: true},
{org: "test", repo: "error", token: "", err: true},
{org: "test", repo: "", token: fake.RegistrationToken, err: false},
{org: "invalid", repo: "", token: "", err: true},
{org: "error", repo: "", token: "", err: true},
}
client := newTestClient()
for i, tt := range tests {
rt, err := client.GetRegistrationToken(context.Background(), tt.org, tt.repo, "test")
if !tt.err && err != nil {
t.Errorf("[%d] unexpected error: %v", i, err)
}
if tt.token != rt.GetToken() {
t.Errorf("[%d] unexpected token: %v", i, rt.GetToken())
}
}
}
func TestListRunners(t *testing.T) {
tests := []struct {
org string
repo string
length int
err bool
}{
{org: "test", repo: "valid", length: 2, err: false},
{org: "test", repo: "invalid", length: 0, err: true},
{org: "test", repo: "error", length: 0, err: true},
{org: "test", repo: "", length: 2, err: false},
{org: "invalid", repo: "", length: 0, err: true},
{org: "error", repo: "", length: 0, err: true},
}
client := newTestClient()
for i, tt := range tests {
runners, err := client.ListRunners(context.Background(), tt.org, tt.repo)
if !tt.err && err != nil {
t.Errorf("[%d] unexpected error: %v", i, err)
}
if tt.length != len(runners) {
t.Errorf("[%d] unexpected runners list: %v", i, runners)
}
}
}
func TestRemoveRunner(t *testing.T) {
tests := []struct {
org string
repo string
err bool
}{
{org: "test", repo: "valid", err: false},
{org: "test", repo: "invalid", err: true},
{org: "test", repo: "error", err: true},
{org: "test", repo: "", err: false},
{org: "invalid", repo: "", err: true},
{org: "error", repo: "", err: true},
}
client := newTestClient()
for i, tt := range tests {
err := client.RemoveRunner(context.Background(), tt.org, tt.repo, int64(1))
if !tt.err && err != nil {
t.Errorf("[%d] unexpected error: %v", i, err)
}
}
}
func TestCleanup(t *testing.T) {
token := "token"
client := newTestClient()
client.regTokens = map[string]*github.RegistrationToken{
"active": &github.RegistrationToken{
Token: &token,
ExpiresAt: &github.Timestamp{Time: time.Now().Add(time.Hour * 1)},
},
"expired": &github.RegistrationToken{
Token: &token,
ExpiresAt: &github.Timestamp{Time: time.Now().Add(-time.Hour * 1)},
},
}
client.cleanup()
if _, ok := client.regTokens["active"]; !ok {
t.Errorf("active token was accidentally removed")
}
if _, ok := client.regTokens["expired"]; ok {
t.Errorf("expired token still exists")
}
}

9
go.mod
View File

@@ -3,18 +3,15 @@ module github.com/summerwind/actions-runner-controller
go 1.13
require (
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d // indirect
github.com/bradleyfalzon/ghinstallation v1.1.1
github.com/davecgh/go-spew v1.1.1
github.com/go-logr/logr v0.1.0
github.com/google/go-github v17.0.0+incompatible
github.com/google/go-github/v29 v29.0.3
github.com/google/go-github/v31 v31.0.0
github.com/google/go-querystring v1.0.0
github.com/onsi/ginkgo v1.8.0
github.com/onsi/gomega v1.5.0
github.com/prometheus/common v0.0.0-20181126121408-4724e9255275
github.com/stretchr/testify v1.4.0 // indirect
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45
gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
k8s.io/api v0.0.0-20190918155943-95b840bb6a1f
k8s.io/apimachinery v0.0.0-20190913080033-27d36303b655
k8s.io/client-go v0.0.0-20190918160344-1fbdaa4c8d90

12
go.sum
View File

@@ -18,10 +18,6 @@ github.com/PuerkitoBio/purell v1.1.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbt
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d h1:UQZhZ2O0vMHr2cI+DC1Mbh0TJxzA3RcLoMsFw+aXw7E=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY=
@@ -121,11 +117,10 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-github/v29 v29.0.2 h1:opYN6Wc7DOz7Ku3Oh4l7prmkOMwEcQxpFtxdU8N8Pts=
github.com/google/go-github/v29 v29.0.2/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E=
github.com/google/go-github/v29 v29.0.3 h1:IktKCTwU//aFHnpA+2SLIi7Oo9uhAzgsdZNbcAqhgdc=
github.com/google/go-github/v29 v29.0.3/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E=
github.com/google/go-github/v31 v31.0.0 h1:JJUxlP9lFK+ziXKimTCprajMApV1ecWD4NB6CCb0plo=
github.com/google/go-github/v31 v31.0.0/go.mod h1:NQPZol8/1sMoWYGN2yaALIBytu17gAWfhbweiEed3pM=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v0.0.0-20161122191042-44d81051d367/go.mod h1:HP5RmnzzSNb993RKQDq4+1A4ia9nllfqcQFTQJedwGI=
@@ -237,6 +232,7 @@ github.com/stretchr/testify v0.0.0-20151208002404-e3a8ff8ce365/go.mod h1:a8OnRci
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
@@ -343,8 +339,6 @@ google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRn
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=

83
main.go
View File

@@ -17,15 +17,14 @@ limitations under the License.
package main
import (
"context"
"flag"
"fmt"
"os"
"strconv"
"github.com/google/go-github/v29/github"
actionsv1alpha1 "github.com/summerwind/actions-runner-controller/api/v1alpha1"
"github.com/summerwind/actions-runner-controller/controllers"
"golang.org/x/oauth2"
"github.com/summerwind/actions-runner-controller/github"
"k8s.io/apimachinery/pkg/runtime"
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
@@ -35,8 +34,8 @@ import (
)
const (
defaultRunnerImage = "summerwind/actions-runner:v2.165.2"
defaultDockerImage = "docker:19.03.6-dind"
defaultRunnerImage = "summerwind/actions-runner:latest"
defaultDockerImage = "docker:dind"
)
var (
@@ -53,12 +52,19 @@ func init() {
func main() {
var (
err error
ghClient *github.Client
metricsAddr string
enableLeaderElection bool
runnerImage string
dockerImage string
ghToken string
ghToken string
ghAppID int64
ghAppInstallationID int64
ghAppPrivateKey string
)
flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "The address the metric endpoint binds to.")
@@ -66,21 +72,57 @@ func main() {
"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.StringVar(&ghToken, "github-token", "", "The personal access token of GitHub.")
flag.Int64Var(&ghAppID, "github-app-id", 0, "The application ID of GitHub App.")
flag.Int64Var(&ghAppInstallationID, "github-app-installation-id", 0, "The installation ID of GitHub App.")
flag.StringVar(&ghAppPrivateKey, "github-app-private-key", "", "The path of a private key file to authenticate as a GitHub App")
flag.Parse()
if ghToken == "" {
ghToken = os.Getenv("GITHUB_TOKEN")
}
if ghToken == "" {
fmt.Fprintln(os.Stderr, "Error: GitHub access token must be specified.")
os.Exit(1)
if ghAppID == 0 {
appID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_ID"), 10, 64)
if err == nil {
ghAppID = appID
}
}
if ghAppInstallationID == 0 {
appInstallationID, err := strconv.ParseInt(os.Getenv("GITHUB_APP_INSTALLATION_ID"), 10, 64)
if err == nil {
ghAppInstallationID = appInstallationID
}
}
if ghAppPrivateKey == "" {
ghAppPrivateKey = os.Getenv("GITHUB_APP_PRIVATE_KEY")
}
tc := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(
&oauth2.Token{AccessToken: ghToken},
))
ghClient := github.NewClient(tc)
if ghAppID != 0 {
if ghAppInstallationID == 0 {
fmt.Fprintln(os.Stderr, "Error: The installation ID must be specified.")
os.Exit(1)
}
if ghAppPrivateKey == "" {
fmt.Fprintln(os.Stderr, "Error: The path of a private key file must be specified.")
os.Exit(1)
}
ghClient, err = github.NewClient(ghAppID, ghAppInstallationID, ghAppPrivateKey)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: Failed to create GitHub client: %v\n", err)
os.Exit(1)
}
} else if ghToken != "" {
ghClient, err = github.NewClientWithAccessToken(ghToken)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: Failed to create GitHub client: %v\n", err)
os.Exit(1)
}
} else {
fmt.Fprintln(os.Stderr, "Error: GitHub App credentials or personal access token must be specified.")
os.Exit(1)
}
ctrl.SetLogger(zap.New(func(o *zap.Options) {
o.Development = true
@@ -132,6 +174,19 @@ func main() {
setupLog.Error(err, "unable to create controller", "controller", "RunnerDeployment")
os.Exit(1)
}
if err = (&actionsv1alpha1.Runner{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "Runner")
os.Exit(1)
}
if err = (&actionsv1alpha1.RunnerDeployment{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "RunnerDeployment")
os.Exit(1)
}
if err = (&actionsv1alpha1.RunnerReplicaSet{}).SetupWebhookWithManager(mgr); err != nil {
setupLog.Error(err, "unable to create webhook", "webhook", "RunnerReplicaSet")
os.Exit(1)
}
// +kubebuilder:scaffold:builder
setupLog.Info("starting manager")

View File

@@ -3,22 +3,56 @@ FROM ubuntu:18.04
ARG RUNNER_VERSION
ARG DOCKER_VERSION
RUN apt update \
&& apt install curl ca-certificates -y --no-install-recommends \
&& curl -L -o docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz \
ENV DEBIAN_FRONTEND=noninteractive
RUN apt update -y \
&& apt install -y software-properties-common \
&& add-apt-repository -y ppa:git-core/ppa \
&& apt update -y \
&& apt install -y --no-install-recommends \
build-essential \
curl \
ca-certificates \
dnsutils \
ftp \
git \
iproute2 \
iputils-ping \
jq \
libunwind8 \
locales \
netcat \
openssh-client \
parallel \
rsync \
shellcheck \
sudo \
telnet \
time \
tzdata \
unzip \
upx \
wget \
zip \
zstd \
&& rm -rf /var/lib/apt/lists/*
RUN curl -L -o docker.tgz https://download.docker.com/linux/static/stable/x86_64/docker-${DOCKER_VERSION}.tgz \
&& 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
&& adduser --disabled-password --gecos "" --uid 1000 runner \
&& usermod -aG sudo runner \
&& echo "%sudo ALL=(ALL:ALL) NOPASSWD:ALL" > /etc/sudoers
RUN mkdir -p /runner \
&& cd /runner \
&& curl -L -o runner.tar.gz https://github.com/actions/runner/releases/download/v${RUNNER_VERSION}/actions-runner-linux-x64-${RUNNER_VERSION}.tar.gz \
&& tar xzf ./runner.tar.gz \
&& rm runner.tar.gz \
&& ./bin/installdependencies.sh
&& ./bin/installdependencies.sh \
&& rm -rf /var/lib/apt/lists/*
COPY entrypoint.sh /runner

View File

@@ -1,7 +1,7 @@
NAME ?= summerwind/actions-runner
RUNNER_VERSION ?= 2.165.2
DOCKER_VERSION ?= 19.03.6
RUNNER_VERSION ?= 2.169.1
DOCKER_VERSION ?= 19.03.8
docker-build:
docker build --build-arg RUNNER_VERSION=${RUNNER_VERSION} --build-arg DOCKER_VERSION=${DOCKER_VERSION} -t ${NAME}:latest -t ${NAME}:v${RUNNER_VERSION} .

View File

@@ -5,18 +5,28 @@ if [ -z "${RUNNER_NAME}" ]; then
exit 1
fi
if [ -z "${RUNNER_REPO}" ]; then
echo "RUNNER_REPO must be set" 1>&2
if [ -n "${RUNNER_ORG}" -a -n "${RUNNER_REPO}" ]; then
ATTACH="${RUNNER_ORG}/${RUNNER_REPO}"
elif [ -n "${RUNNER_ORG}" ]; then
ATTACH="${RUNNER_ORG}"
elif [ -n "${RUNNER_REPO}" ]; then
ATTACH="${RUNNER_REPO}"
else
echo "At least one of RUNNER_ORG or RUNNER_REPO must be set" 1>&2
exit 1
fi
if [ -n "${RUNNER_LABELS}" ]; then
LABEL_ARG="--labels ${RUNNER_LABELS}"
fi
if [ -z "${RUNNER_TOKEN}" ]; then
echo "RUNNER_TOKEN must be set" 1>&2
exit 1
fi
cd /runner
./config.sh --unattended --replace --name "${RUNNER_NAME}" --url "https://github.com/${RUNNER_REPO}" --token "${RUNNER_TOKEN}"
./config.sh --unattended --replace --name "${RUNNER_NAME}" --url "https://github.com/${ATTACH}" --token "${RUNNER_TOKEN}" ${LABEL_ARG}
unset RUNNER_NAME RUNNER_REPO RUNNER_TOKEN
exec ./run.sh --once