mirror of
https://github.com/actions/actions-runner-controller.git
synced 2025-12-10 11:41:27 +00:00
We occasionally encountered those errors while the underlying RunnerReplicaSet is being recreated/replaced on RunnerDeployment.Spec.Template update. It turned out to be due to that the RunnerDeployment controller was waiting for the runner pod becomes `Running`, intead of the new replacement runner to have registered to GitHub. This fixes that, by trying to Runner.Status.Phase to `Running` only after the runner in the runner pod appears to be registered. A side-effect of this change is that runner controller would call more "ListRunners" GitHub Actions API. I've reviewed and improved the runner controller code and Runner CRD to make make the number of calls minimum. In most cases, ListRunners should be called only twice for each runner creation.
348 lines
9.9 KiB
Go
348 lines
9.9 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/bradleyfalzon/ghinstallation"
|
|
"github.com/google/go-github/v33/github"
|
|
"github.com/summerwind/actions-runner-controller/github/metrics"
|
|
"golang.org/x/oauth2"
|
|
)
|
|
|
|
// Config contains configuration for Github client
|
|
type Config struct {
|
|
EnterpriseURL string `split_words:"true"`
|
|
AppID int64 `split_words:"true"`
|
|
AppInstallationID int64 `split_words:"true"`
|
|
AppPrivateKey string `split_words:"true"`
|
|
Token string
|
|
}
|
|
|
|
// Client wraps GitHub client with some additional
|
|
type Client struct {
|
|
*github.Client
|
|
regTokens map[string]*github.RegistrationToken
|
|
mu sync.Mutex
|
|
// GithubBaseURL to Github without API suffix.
|
|
GithubBaseURL string
|
|
}
|
|
|
|
// NewClient creates a Github Client
|
|
func (c *Config) NewClient() (*Client, error) {
|
|
var transport http.RoundTripper
|
|
if len(c.Token) > 0 {
|
|
transport = oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{AccessToken: c.Token})).Transport
|
|
} else {
|
|
var tr *ghinstallation.Transport
|
|
|
|
if _, err := os.Stat(c.AppPrivateKey); err == nil {
|
|
tr, err = ghinstallation.NewKeyFromFile(http.DefaultTransport, c.AppID, c.AppInstallationID, c.AppPrivateKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("authentication failed: using private key at %s: %v", c.AppPrivateKey, err)
|
|
}
|
|
} else {
|
|
tr, err = ghinstallation.New(http.DefaultTransport, c.AppID, c.AppInstallationID, []byte(c.AppPrivateKey))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("authentication failed: using private key of size %d (%s...): %v", len(c.AppPrivateKey), strings.Split(c.AppPrivateKey, "\n")[0], err)
|
|
}
|
|
}
|
|
|
|
if len(c.EnterpriseURL) > 0 {
|
|
githubAPIURL, err := getEnterpriseApiUrl(c.EnterpriseURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("enterprise url incorrect: %v", err)
|
|
}
|
|
tr.BaseURL = githubAPIURL
|
|
}
|
|
transport = tr
|
|
}
|
|
transport = metrics.Transport{Transport: transport}
|
|
httpClient := &http.Client{Transport: transport}
|
|
|
|
var client *github.Client
|
|
var githubBaseURL string
|
|
if len(c.EnterpriseURL) > 0 {
|
|
var err error
|
|
client, err = github.NewEnterpriseClient(c.EnterpriseURL, c.EnterpriseURL, httpClient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("enterprise client creation failed: %v", err)
|
|
}
|
|
githubBaseURL = fmt.Sprintf("%s://%s%s", client.BaseURL.Scheme, client.BaseURL.Host, strings.TrimSuffix(client.BaseURL.Path, "api/v3/"))
|
|
} else {
|
|
client = github.NewClient(httpClient)
|
|
githubBaseURL = "https://github.com/"
|
|
}
|
|
|
|
return &Client{
|
|
Client: client,
|
|
regTokens: map[string]*github.RegistrationToken{},
|
|
mu: sync.Mutex{},
|
|
GithubBaseURL: githubBaseURL,
|
|
}, nil
|
|
}
|
|
|
|
// GetRegistrationToken returns a registration token tied with the name of repository and runner.
|
|
func (c *Client) GetRegistrationToken(ctx context.Context, enterprise, org, repo, name string) (*github.RegistrationToken, error) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
key := getRegistrationKey(org, repo, enterprise)
|
|
rt, ok := c.regTokens[key]
|
|
|
|
// we like to give runners a chance that are just starting up and may miss the expiration date by a bit
|
|
runnerStartupTimeout := 3 * time.Minute
|
|
|
|
if ok && rt.GetExpiresAt().After(time.Now().Add(runnerStartupTimeout)) {
|
|
return rt, nil
|
|
}
|
|
|
|
enterprise, owner, repo, err := getEnterpriseOrganisationAndRepo(enterprise, org, repo)
|
|
|
|
if err != nil {
|
|
return rt, err
|
|
}
|
|
|
|
rt, res, err := c.createRegistrationToken(ctx, enterprise, 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 repository.
|
|
func (c *Client) RemoveRunner(ctx context.Context, enterprise, org, repo string, runnerID int64) error {
|
|
enterprise, owner, repo, err := getEnterpriseOrganisationAndRepo(enterprise, org, repo)
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
res, err := c.removeRunner(ctx, enterprise, owner, repo, runnerID)
|
|
|
|
if err != nil {
|
|
return fmt.Errorf("failed to remove runner: %w", 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, enterprise, org, repo string) ([]*github.Runner, error) {
|
|
enterprise, owner, repo, err := getEnterpriseOrganisationAndRepo(enterprise, org, repo)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var runners []*github.Runner
|
|
|
|
opts := github.ListOptions{PerPage: 100}
|
|
for {
|
|
list, res, err := c.listRunners(ctx, enterprise, owner, repo, &opts)
|
|
|
|
if err != nil {
|
|
return runners, fmt.Errorf("failed to list runners: %w", 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 enterprise/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, enterprise, org, repo string) (*github.RegistrationToken, *github.Response, error) {
|
|
if len(repo) > 0 {
|
|
return c.Client.Actions.CreateRegistrationToken(ctx, org, repo)
|
|
}
|
|
if len(org) > 0 {
|
|
return c.Client.Actions.CreateOrganizationRegistrationToken(ctx, org)
|
|
}
|
|
return c.Client.Enterprise.CreateRegistrationToken(ctx, enterprise)
|
|
}
|
|
|
|
func (c *Client) removeRunner(ctx context.Context, enterprise, org, repo string, runnerID int64) (*github.Response, error) {
|
|
if len(repo) > 0 {
|
|
return c.Client.Actions.RemoveRunner(ctx, org, repo, runnerID)
|
|
}
|
|
if len(org) > 0 {
|
|
return c.Client.Actions.RemoveOrganizationRunner(ctx, org, runnerID)
|
|
}
|
|
return c.Client.Enterprise.RemoveRunner(ctx, enterprise, runnerID)
|
|
}
|
|
|
|
func (c *Client) listRunners(ctx context.Context, enterprise, org, repo string, opts *github.ListOptions) (*github.Runners, *github.Response, error) {
|
|
if len(repo) > 0 {
|
|
return c.Client.Actions.ListRunners(ctx, org, repo, opts)
|
|
}
|
|
if len(org) > 0 {
|
|
return c.Client.Actions.ListOrganizationRunners(ctx, org, opts)
|
|
}
|
|
return c.Client.Enterprise.ListRunners(ctx, enterprise, opts)
|
|
}
|
|
|
|
func (c *Client) ListRepositoryWorkflowRuns(ctx context.Context, user string, repoName string) ([]*github.WorkflowRun, error) {
|
|
queued, err := c.listRepositoryWorkflowRuns(ctx, user, repoName, "queued")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing queued workflow runs: %w", err)
|
|
}
|
|
|
|
inProgress, err := c.listRepositoryWorkflowRuns(ctx, user, repoName, "in_progress")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listing in_progress workflow runs: %w", err)
|
|
}
|
|
|
|
var workflowRuns []*github.WorkflowRun
|
|
|
|
workflowRuns = append(workflowRuns, queued...)
|
|
workflowRuns = append(workflowRuns, inProgress...)
|
|
|
|
return workflowRuns, nil
|
|
}
|
|
|
|
func (c *Client) listRepositoryWorkflowRuns(ctx context.Context, user string, repoName, status string) ([]*github.WorkflowRun, error) {
|
|
var workflowRuns []*github.WorkflowRun
|
|
|
|
opts := github.ListWorkflowRunsOptions{
|
|
ListOptions: github.ListOptions{
|
|
PerPage: 100,
|
|
},
|
|
Status: status,
|
|
}
|
|
|
|
for {
|
|
list, res, err := c.Client.Actions.ListRepositoryWorkflowRuns(ctx, user, repoName, &opts)
|
|
|
|
if err != nil {
|
|
return workflowRuns, fmt.Errorf("failed to list workflow runs: %v", err)
|
|
}
|
|
|
|
workflowRuns = append(workflowRuns, list.WorkflowRuns...)
|
|
if res.NextPage == 0 {
|
|
break
|
|
}
|
|
opts.Page = res.NextPage
|
|
}
|
|
|
|
return workflowRuns, nil
|
|
}
|
|
|
|
// Validates enterprise, organisation and repo arguments. Both are optional, but at least one should be specified
|
|
func getEnterpriseOrganisationAndRepo(enterprise, org, repo string) (string, string, string, error) {
|
|
if len(repo) > 0 {
|
|
owner, repository, err := splitOwnerAndRepo(repo)
|
|
return "", owner, repository, err
|
|
}
|
|
if len(org) > 0 {
|
|
return "", org, "", nil
|
|
}
|
|
if len(enterprise) > 0 {
|
|
return enterprise, "", "", nil
|
|
}
|
|
return "", "", "", fmt.Errorf("enterprise, organization and repository are all empty")
|
|
}
|
|
|
|
func getRegistrationKey(org, repo, enterprise string) string {
|
|
return fmt.Sprintf("org=%s,repo=%s,enterprise=%s", org, repo, enterprise)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
func getEnterpriseApiUrl(baseURL string) (string, error) {
|
|
baseEndpoint, err := url.Parse(baseURL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if !strings.HasSuffix(baseEndpoint.Path, "/") {
|
|
baseEndpoint.Path += "/"
|
|
}
|
|
if !strings.HasSuffix(baseEndpoint.Path, "/api/v3/") &&
|
|
!strings.HasPrefix(baseEndpoint.Host, "api.") &&
|
|
!strings.Contains(baseEndpoint.Host, ".api.") {
|
|
baseEndpoint.Path += "api/v3/"
|
|
}
|
|
|
|
// Trim trailing slash, otherwise there's double slash added to token endpoint
|
|
return fmt.Sprintf("%s://%s%s", baseEndpoint.Scheme, baseEndpoint.Host, strings.TrimSuffix(baseEndpoint.Path, "/")), nil
|
|
}
|
|
|
|
type RunnerNotFound struct {
|
|
runnerName string
|
|
}
|
|
|
|
func (e *RunnerNotFound) Error() string {
|
|
return fmt.Sprintf("runner %q not found", e.runnerName)
|
|
}
|
|
|
|
type RunnerOffline struct {
|
|
runnerName string
|
|
}
|
|
|
|
func (e *RunnerOffline) Error() string {
|
|
return fmt.Sprintf("runner %q offline", e.runnerName)
|
|
}
|
|
|
|
func (r *Client) IsRunnerBusy(ctx context.Context, enterprise, org, repo, name string) (bool, error) {
|
|
runners, err := r.ListRunners(ctx, enterprise, org, repo)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
for _, runner := range runners {
|
|
if runner.GetName() == name {
|
|
if runner.GetStatus() == "offline" {
|
|
return false, &RunnerOffline{runnerName: name}
|
|
}
|
|
return runner.GetBusy(), nil
|
|
}
|
|
}
|
|
|
|
return false, &RunnerNotFound{runnerName: name}
|
|
}
|