diff --git a/api/v1alpha1/runner_types.go b/api/v1alpha1/runner_types.go index 6c275a4e..c8266a62 100644 --- a/api/v1alpha1/runner_types.go +++ b/api/v1alpha1/runner_types.go @@ -24,7 +24,11 @@ import ( // RunnerSpec defines the desired state of Runner type RunnerSpec struct { // +kubebuilder:validation:MinLength=3 - // +kubebuilder:validation:Pattern=`^[^/]+/[^/]+$` + // +kubebuilder:validation:Pattern=`^[^/]+$` + Organization string `json:"organization"` + + // +optional + // +kubebuilder:validation:Pattern=`^[^/]*$` Repository string `json:"repository"` // +optional diff --git a/config/crd/bases/actions.summerwind.dev_runnerdeployments.yaml b/config/crd/bases/actions.summerwind.dev_runnerdeployments.yaml index e1b53f38..3158dbdc 100644 --- a/config/crd/bases/actions.summerwind.dev_runnerdeployments.yaml +++ b/config/crd/bases/actions.summerwind.dev_runnerdeployments.yaml @@ -4115,9 +4115,12 @@ spec: additionalProperties: type: string type: object - repository: + organization: minLength: 3 - pattern: ^[^/]+/[^/]+$ + pattern: ^[^/]+$ + type: string + repository: + pattern: ^[^/]*$ type: string resources: description: ResourceRequirements describes the compute resource @@ -6709,7 +6712,7 @@ spec: type: object type: array required: - - repository + - organization type: object type: object required: diff --git a/config/crd/bases/actions.summerwind.dev_runnerreplicasets.yaml b/config/crd/bases/actions.summerwind.dev_runnerreplicasets.yaml index 6ef86604..fdc49396 100644 --- a/config/crd/bases/actions.summerwind.dev_runnerreplicasets.yaml +++ b/config/crd/bases/actions.summerwind.dev_runnerreplicasets.yaml @@ -4115,9 +4115,12 @@ spec: additionalProperties: type: string type: object - repository: + organization: minLength: 3 - pattern: ^[^/]+/[^/]+$ + pattern: ^[^/]+$ + type: string + repository: + pattern: ^[^/]*$ type: string resources: description: ResourceRequirements describes the compute resource @@ -6709,7 +6712,7 @@ spec: type: object type: array required: - - repository + - organization type: object type: object required: diff --git a/config/crd/bases/actions.summerwind.dev_runners.yaml b/config/crd/bases/actions.summerwind.dev_runners.yaml index 0681d35b..ccb0a965 100644 --- a/config/crd/bases/actions.summerwind.dev_runners.yaml +++ b/config/crd/bases/actions.summerwind.dev_runners.yaml @@ -3858,9 +3858,12 @@ spec: additionalProperties: type: string type: object - repository: + organization: minLength: 3 - pattern: ^[^/]+/[^/]+$ + pattern: ^[^/]+$ + type: string + repository: + pattern: ^[^/]*$ type: string resources: description: ResourceRequirements describes the compute resource requirements. @@ -6293,7 +6296,7 @@ spec: type: object type: array required: - - repository + - organization type: object status: description: RunnerStatus defines the observed state of Runner diff --git a/controllers/runner_controller.go b/controllers/runner_controller.go index 293297e8..9454e751 100644 --- a/controllers/runner_controller.go +++ b/controllers/runner_controller.go @@ -83,7 +83,7 @@ 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) + 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 @@ -108,7 +108,7 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { } if !runner.IsRegisterable() { - rt, err := r.GitHubClient.GetRegistrationToken(ctx, runner.Spec.Repository, runner.Name) + 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") @@ -212,8 +212,8 @@ func (r *RunnerReconciler) Reconcile(req ctrl.Request) (ctrl.Result, error) { return ctrl.Result{}, nil } -func (r *RunnerReconciler) unregisterRunner(ctx context.Context, repo, name string) (bool, error) { - runners, err := r.GitHubClient.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 } @@ -230,7 +230,7 @@ func (r *RunnerReconciler) unregisterRunner(ctx context.Context, repo, name stri return false, nil } - if err := r.GitHubClient.RemoveRunner(ctx, repo, id); err != nil { + if err := r.GitHubClient.RemoveRunner(ctx, org, repo, id); err != nil { return false, err } @@ -253,6 +253,10 @@ 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, diff --git a/github/fake/fake.go b/github/fake/fake.go index a9ed3667..28bf9046 100644 --- a/github/fake/fake.go +++ b/github/fake/fake.go @@ -31,6 +31,7 @@ func (h *handler) ServeHTTP(w http.ResponseWriter, req *http.Request) { fmt.Fprintf(w, h.Body) } +// NewServer creates a fake server for running unit tests func NewServer() *httptest.Server { routes := map[string]handler{ // For CreateRegistrationToken @@ -46,6 +47,18 @@ func NewServer() *httptest.Server { 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{ @@ -60,6 +73,18 @@ func NewServer() *httptest.Server { 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{ @@ -74,6 +99,18 @@ func NewServer() *httptest.Server { 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() diff --git a/github/github.go b/github/github.go index 401eee4f..6bcd5518 100644 --- a/github/github.go +++ b/github/github.go @@ -2,10 +2,8 @@ package github import ( "context" - "errors" "fmt" "net/http" - "strings" "sync" "time" @@ -14,6 +12,7 @@ import ( "golang.org/x/oauth2" ) +// Client wraps GitHub client with some additional type Client struct { *github.Client regTokens map[string]*github.RegistrationToken @@ -34,7 +33,7 @@ func NewClient(appID, installationID int64, privateKeyPath string) (*Client, err }, nil } -// NewClient returns a client authenticated with personal access token. +// 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}, @@ -48,22 +47,31 @@ func NewClientWithAccessToken(token string) (*Client, error) { } // GetRegistrationToken returns a registration token tied with the name of repository and runner. -func (c *Client) GetRegistrationToken(ctx context.Context, repository, name string) (*github.RegistrationToken, error) { +func (c *Client) GetRegistrationToken(ctx context.Context, owner, repo, name string) (*github.RegistrationToken, error) { c.mu.Lock() defer c.mu.Unlock() - owner, repo, err := splitOwnerAndRepo(repository) - if err != nil { - return nil, err + key := owner + if len(repo) > 0 { + key = fmt.Sprintf("%s/%s", repo, name) } - key := fmt.Sprintf("%s/%s", repo, name) + var rt *github.RegistrationToken + rt, ok := c.regTokens[key] if ok && rt.GetExpiresAt().After(time.Now().Add(-10*time.Minute)) { return rt, nil } - rt, res, err := c.Client.Actions.CreateRegistrationToken(ctx, owner, repo) + var res *github.Response + var err error + + if len(repo) > 0 { + rt, res, err = c.Client.Actions.CreateRegistrationToken(ctx, owner, repo) + } else { + rt, res, err = CreateOrganizationRegistrationToken(ctx, c, owner) + } + if err != nil { return nil, fmt.Errorf("failed to create registration token: %v", err) } @@ -81,13 +89,16 @@ func (c *Client) GetRegistrationToken(ctx context.Context, repository, name stri } // RemoveRunner removes a runner with specified runner ID from repocitory. -func (c *Client) RemoveRunner(ctx context.Context, repository string, runnerID int64) error { - owner, repo, err := splitOwnerAndRepo(repository) - if err != nil { - return err +func (c *Client) RemoveRunner(ctx context.Context, owner, repo string, runnerID int64) error { + var res *github.Response + var err error + + if len(repo) > 0 { + res, err = c.Client.Actions.RemoveRunner(ctx, owner, repo, runnerID) + } else { + res, err = RemoveOrganizationRunner(ctx, c, owner, runnerID) } - res, err := c.Client.Actions.RemoveRunner(ctx, owner, repo, runnerID) if err != nil { return fmt.Errorf("failed to remove runner: %v", err) } @@ -99,18 +110,22 @@ func (c *Client) RemoveRunner(ctx context.Context, repository string, runnerID i return nil } -// ListRunners returns a list of runners of specified repository name. -func (c *Client) ListRunners(ctx context.Context, repository string) ([]*github.Runner, error) { +// ListRunners returns a list of runners of specified owner/repository name. +func (c *Client) ListRunners(ctx context.Context, owner, repo string) ([]*github.Runner, error) { var runners []*github.Runner - owner, repo, err := splitOwnerAndRepo(repository) - if err != nil { - return runners, err - } - opts := github.ListOptions{PerPage: 10} for { - list, res, err := c.Client.Actions.ListRunners(ctx, owner, repo, &opts) + list := &github.Runners{} + var res *github.Response + var err error + + if len(repo) > 0 { + list, res, err = c.Client.Actions.ListRunners(ctx, owner, repo, &opts) + } else { + list, res, err = ListOrganizationRunners(ctx, c, owner, &opts) + } + if err != nil { return runners, fmt.Errorf("failed to remove runner: %v", err) } @@ -136,12 +151,3 @@ func (c *Client) cleanup() { } } } - -// splitOwnerAndRepo splits specified repository name to the owner and repo name. -func splitOwnerAndRepo(repo string) (string, string, error) { - chunk := strings.Split(repo, "/") - if len(chunk) != 2 { - return "", "", errors.New("invalid repository name") - } - return chunk[0], chunk[1], nil -} diff --git a/github/github_beta.go b/github/github_beta.go new file mode 100644 index 00000000..f690b35f --- /dev/null +++ b/github/github_beta.go @@ -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 +} diff --git a/github/github_test.go b/github/github_test.go index bd048efa..8cfbd33c 100644 --- a/github/github_test.go +++ b/github/github_test.go @@ -36,18 +36,22 @@ func TestMain(m *testing.M) { func TestGetRegistrationToken(t *testing.T) { tests := []struct { + org string repo string token string err bool }{ - {repo: "test/valid", token: fake.RegistrationToken, err: false}, - {repo: "test/invalid", token: "", err: true}, - {repo: "test/error", token: "", err: true}, + {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.repo, "test") + rt, err := client.GetRegistrationToken(context.Background(), tt.org, tt.repo, "test") if !tt.err && err != nil { t.Errorf("[%d] unexpected error: %v", i, err) } @@ -59,18 +63,22 @@ func TestGetRegistrationToken(t *testing.T) { func TestListRunners(t *testing.T) { tests := []struct { + org string repo string length int err bool }{ - {repo: "test/valid", length: 2, err: false}, - {repo: "test/invalid", length: 0, err: true}, - {repo: "test/error", length: 0, err: true}, + {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.repo) + runners, err := client.ListRunners(context.Background(), tt.org, tt.repo) if !tt.err && err != nil { t.Errorf("[%d] unexpected error: %v", i, err) } @@ -82,17 +90,21 @@ func TestListRunners(t *testing.T) { func TestRemoveRunner(t *testing.T) { tests := []struct { + org string repo string err bool }{ - {repo: "test/valid", err: false}, - {repo: "test/invalid", err: true}, - {repo: "test/error", err: true}, + {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.repo, int64(1)) + err := client.RemoveRunner(context.Background(), tt.org, tt.repo, int64(1)) if !tt.err && err != nil { t.Errorf("[%d] unexpected error: %v", i, err) } diff --git a/go.mod b/go.mod index 342f99a2..a81bb556 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/davecgh/go-spew v1.1.1 github.com/go-logr/logr v0.1.0 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/stretchr/testify v1.4.0 // indirect diff --git a/go.sum b/go.sum index 9c79b86d..adb85fd2 100644 --- a/go.sum +++ b/go.sum @@ -116,6 +116,7 @@ github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 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/v29 v29.0.2 h1:opYN6Wc7DOz7Ku3Oh4l7prmkOMwEcQxpFtxdU8N8Pts= github.com/google/go-github/v29 v29.0.2/go.mod h1:CHKiKKPHJ0REzfwc14QMklvtHwCveD0PxlMjLlzAM5E= github.com/google/go-github/v31 v31.0.0 h1:JJUxlP9lFK+ziXKimTCprajMApV1ecWD4NB6CCb0plo= diff --git a/runner/entrypoint.sh b/runner/entrypoint.sh index 5bef0a63..ca59a7cf 100755 --- a/runner/entrypoint.sh +++ b/runner/entrypoint.sh @@ -5,18 +5,24 @@ if [ -z "${RUNNER_NAME}" ]; then exit 1 fi -if [ -z "${RUNNER_REPO}" ]; then - echo "RUNNER_REPO must be set" 1>&2 +if [ -z "${RUNNER_ORG}" ]; then + echo "RUNNER_ORG must be set" 1>&2 exit 1 fi +ATTACH="${RUNNER_ORG}" + +if [ ! -z "${RUNNER_REPO}" ]; then + ATTACH="${RUNNER_ORG}/${RUNNER_REPO}" +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}" unset RUNNER_NAME RUNNER_REPO RUNNER_TOKEN exec ./run.sh --once