From 8b43e9e5c90a7b3a3e69d996f03a7db328b0f39b Mon Sep 17 00:00:00 2001 From: Nikola Jokic Date: Thu, 30 Oct 2025 10:15:01 +0100 Subject: [PATCH] Initial commit containing ARC github/actions client --- .github/workflows/go.yaml | 55 + .golangci.yaml | 11 + actions_server_test.go | 113 ++ byte_order_mark_test.go | 61 ++ client.go | 1275 +++++++++++++++++++++++ client_generate_jit_test.go | 61 ++ client_job_acquisition_test.go | 171 +++ client_proxy_test.go | 39 + client_runner_scale_set_message_test.go | 248 +++++ client_runner_scale_set_session_test.go | 220 ++++ client_runner_scale_set_test.go | 424 ++++++++ client_runner_test.go | 218 ++++ client_tls_test.go | 178 ++++ config.go | 109 ++ config_test.go | 196 ++++ errors.go | 125 +++ errors_test.go | 206 ++++ github_api_request_test.go | 248 +++++ go.mod | 29 + go.sum | 87 ++ identifier_test.go | 158 +++ mock_ActionsService.go | 428 ++++++++ mock_SessionService.go | 108 ++ sessionservice.go | 14 + testdata/generate.sh | 29 + testdata/intermediate.crt | 19 + testdata/leaf.crt | 20 + testdata/leaf.key | 28 + testdata/rootCA.crt | 19 + testdata/server.crt | 20 + testdata/server.key | 28 + testserver/server.go | 159 +++ types.go | 158 +++ user_agent_test.go | 24 + 34 files changed, 5286 insertions(+) create mode 100644 .github/workflows/go.yaml create mode 100644 .golangci.yaml create mode 100644 actions_server_test.go create mode 100644 byte_order_mark_test.go create mode 100644 client.go create mode 100644 client_generate_jit_test.go create mode 100644 client_job_acquisition_test.go create mode 100644 client_proxy_test.go create mode 100644 client_runner_scale_set_message_test.go create mode 100644 client_runner_scale_set_session_test.go create mode 100644 client_runner_scale_set_test.go create mode 100644 client_runner_test.go create mode 100644 client_tls_test.go create mode 100644 config.go create mode 100644 config_test.go create mode 100644 errors.go create mode 100644 errors_test.go create mode 100644 github_api_request_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 identifier_test.go create mode 100644 mock_ActionsService.go create mode 100644 mock_SessionService.go create mode 100644 sessionservice.go create mode 100644 testdata/generate.sh create mode 100644 testdata/intermediate.crt create mode 100644 testdata/leaf.crt create mode 100644 testdata/leaf.key create mode 100644 testdata/rootCA.crt create mode 100644 testdata/server.crt create mode 100644 testdata/server.key create mode 100644 testserver/server.go create mode 100644 types.go create mode 100644 user_agent_test.go diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml new file mode 100644 index 0000000..20b4257 --- /dev/null +++ b/.github/workflows/go.yaml @@ -0,0 +1,55 @@ +name: Go +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +concurrency: + # This will make sure we only apply the concurrency limits on pull requests + # but not pushes to master branch by making the concurrency group name unique + # for pushes + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + fmt: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" + cache: false + - name: fmt + run: go fmt ./... + - name: Check diff + run: git diff --exit-code + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" + cache: false + - name: golangci-lint + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 + with: + only-new-issues: true + version: v2.5.0 + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: actions/setup-go@v6 + with: + go-version-file: "go.mod" + cache: false + - name: Run tests + run: go test ./... \ No newline at end of file diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..06f9f8d --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,11 @@ +version: "2" +run: + timeout: 5m +linters: + settings: + errcheck: + exclude-functions: + - (net/http.ResponseWriter).Write + exclusions: + presets: + - std-error-handling \ No newline at end of file diff --git a/actions_server_test.go b/actions_server_test.go new file mode 100644 index 0000000..e13de85 --- /dev/null +++ b/actions_server_test.go @@ -0,0 +1,113 @@ +package scaleset_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/require" +) + +// newActionsServer returns a new httptest.Server that handles the +// authentication requests neeeded to create a new client. Any requests not +// made to the /actions/runners/registration-token or +// /actions/runner-registration endpoints will be handled by the provided +// handler. The returned server is started and will be automatically closed +// when the test ends. +func newActionsServer(t *testing.T, handler http.Handler, options ...actionsServerOption) *actionsServer { + s := httptest.NewServer(nil) + server := &actionsServer{ + Server: s, + } + t.Cleanup(func() { + server.Close() + }) + + for _, option := range options { + option(server) + } + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // handle getRunnerRegistrationToken + if strings.HasSuffix(r.URL.Path, "/runners/registration-token") { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"token":"token"}`)) + return + } + + // handle getActionsServiceAdminConnection + if strings.HasSuffix(r.URL.Path, "/actions/runner-registration") { + if server.token == "" { + server.token = defaultActionsToken(t) + } + + w.Write([]byte(`{"url":"` + s.URL + `/tenant/123/","token":"` + server.token + `"}`)) + return + } + + handler.ServeHTTP(w, r) + }) + + server.Config.Handler = h + + return server +} + +type actionsServerOption func(*actionsServer) + +type actionsServer struct { + *httptest.Server + + token string +} + +func (s *actionsServer) configURLForOrg(org string) string { + return s.URL + "/" + org +} + +func defaultActionsToken(t *testing.T) string { + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), + Issuer: "123", + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(samplePrivateKey)) + require.NoError(t, err) + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + return tokenString +} + +const samplePrivateKey = `-----BEGIN PRIVATE KEY----- +MIIEugIBADANBgkqhkiG9w0BAQEFAASCBKQwggSgAgEAAoIBAQC7tgquvNIp+Ik3 +rRVZ9r0zJLsSzTHqr2dA6EUUmpRiQ25MzjMqKqu0OBwvh/pZyfjSIkKrhIridNK4 +DWnPfPWHE2K3Muh0X2sClxtqiiFmXsvbiTzhUm5a+zCcv0pJCWYnKi0HmyXpAXjJ +iN8mWliZN896verVYXWrod7EaAnuST4TiJeqZYW4bBBG81fPNc/UP4j6CKAW8nx9 +HtcX6ApvlHeCLZUTW/qhGLO0nLKoEOr3tXCPW5VjKzlm134Dl+8PN6f1wv6wMAoA +lo7Ha5+c74jhPL6gHXg7cRaHQmuJCJrtl8qbLkFAulfkBixBw/6i11xoM/MOC64l +TWmXqrxTAgMBAAECgf9zYlxfL+rdHRXCoOm7pUeSPL0dWaPFP12d/Z9LSlDAt/h6 +Pd+eqYEwhf795SAbJuzNp51Ls6LUGnzmLOdojKwfqJ51ahT1qbcBcMZNOcvtGqZ9 +xwLG993oyR49C361Lf2r8mKrdrR5/fW0B1+1s6A+eRFivqFOtsOc4V4iMeHYsCVJ +hM7yMu0UfpolDJA/CzopsoGq3UuQlibUEUxKULza06aDjg/gBH3PnP+fQ1m0ovDY +h0pX6SCq5fXVJFS+Pbpu7j2ePNm3mr0qQhrUONZq0qhGN/piCbBZe1CqWApyO7nA +B95VChhL1eYs1BKvQePh12ap83woIUcW2mJF2F0CgYEA+aERTuKWEm+zVNKS9t3V +qNhecCOpayKM9OlALIK/9W6KBS+pDsjQQteQAUAItjvLiDjd5KsrtSgjbSgr66IP +b615Pakywe5sdnVGzSv+07KMzuFob9Hj6Xv9als9Y2geVhUZB2Frqve/UCjmC56i +zuQTSele5QKCSSTFBV3423cCgYEAwIBv9ChsI+mse6vPaqSPpZ2n237anThMcP33 +aS0luYXqMWXZ0TQ/uSmCElY4G3xqNo8szzfy6u0HpldeUsEUsIcBNUV5kIIb8wKu +Zmgcc8gBIjJkyUJI4wuz9G/fegEUj3u6Cttmmj4iWLzCRscRJdfGpqwRIhOGyXb9 +2Rur5QUCgYAGWIPaH4R1H4XNiDTYNbdyvV1ZOG7cHFq89xj8iK5cjNzRWO7RQ2WX +7WbpwTj3ePmpktiBMaDA0C5mXfkP2mTOD/jfCmgR6f+z2zNbj9zAgO93at9+yDUl +AFPm2j7rQgBTa+HhACb+h6HDZebDMNsuqzmaTWZuJ+wr89VWV5c17QKBgH3jwNNQ +mCAIUidynaulQNfTOZIe7IMC7WK7g9CBmPkx7Y0uiXr6C25hCdJKFllLTP6vNWOy +uCcQqf8LhgDiilBDifO3op9xpyuOJlWMYocJVkxx3l2L/rSU07PYcbKNAFAxXuJ4 +xym51qZnkznMN5ei/CPFxVKeqHgaXDpekVStAoGAV3pSWAKDXY/42XEHixrCTqLW +kBxfaf3g7iFnl3u8+7Z/7Cb4ZqFcw0bRJseKuR9mFvBhcZxSErbMDEYrevefU9aM +APeCxEyw6hJXgbWKoG7Fw2g2HP3ytCJ4YzH0zNitHjk/1h4BG7z8cEQILCSv5mN2 +etFcaQuTHEZyRhhJ4BU= +-----END PRIVATE KEY-----` diff --git a/byte_order_mark_test.go b/byte_order_mark_test.go new file mode 100644 index 0000000..81a6cf2 --- /dev/null +++ b/byte_order_mark_test.go @@ -0,0 +1,61 @@ +package scaleset_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/actions/scaleset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_Do(t *testing.T) { + t.Run("trims byte order mark from response if present", func(t *testing.T) { + t.Run("when there is no body", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + })) + defer server.Close() + + client, err := scaleset.NewClient("https://localhost/org/repo", &scaleset.ActionsAuth{Token: "token"}) + require.NoError(t, err) + + req, err := http.NewRequest("GET", server.URL, nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Empty(t, string(body)) + }) + + responses := []string{ + "\xef\xbb\xbf{\"foo\":\"bar\"}", + "{\"foo\":\"bar\"}", + } + + for _, response := range responses { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(response)) + })) + defer server.Close() + + client, err := scaleset.NewClient("https://localhost/org/repo", &scaleset.ActionsAuth{Token: "token"}) + require.NoError(t, err) + + req, err := http.NewRequest("GET", server.URL, nil) + require.NoError(t, err) + + resp, err := client.Do(req) + require.NoError(t, err) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, "{\"foo\":\"bar\"}", string(body)) + } + }) +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..953c2c8 --- /dev/null +++ b/client.go @@ -0,0 +1,1275 @@ +// Package scaleset provides a client for GitHub Actions Runner Scale Set API. +package scaleset + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "maps" + "math/rand" + "net/http" + "net/url" + "strconv" + "sync" + "time" + + "github.com/go-logr/logr" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/hashicorp/go-retryablehttp" +) + +const ( + runnerEndpoint = "_apis/distributedtask/pools/0/agents" + scaleSetEndpoint = "_apis/runtime/runnerscalesets" + apiVersionQueryParam = "api-version=6.0-preview" +) + +// Header used to propagate capacity information to the back-end +const HeaderScaleSetMaxCapacity = "X-ScaleSetMaxCapacity" + +//go:generate mockery --inpackage --name=ActionsService +type ActionsService interface { + GetRunnerScaleSet(ctx context.Context, runnerGroupId int, runnerScaleSetName string) (*RunnerScaleSet, error) + GetRunnerScaleSetById(ctx context.Context, runnerScaleSetId int) (*RunnerScaleSet, error) + GetRunnerGroupByName(ctx context.Context, runnerGroup string) (*RunnerGroup, error) + CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) + UpdateRunnerScaleSet(ctx context.Context, runnerScaleSetId int, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) + DeleteRunnerScaleSet(ctx context.Context, runnerScaleSetId int) error + + CreateMessageSession(ctx context.Context, runnerScaleSetId int, owner string) (*RunnerScaleSetSession, error) + DeleteMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) error + RefreshMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) (*RunnerScaleSetSession, error) + + AcquireJobs(ctx context.Context, runnerScaleSetId int, messageQueueAccessToken string, requestIds []int64) ([]int64, error) + GetAcquirableJobs(ctx context.Context, runnerScaleSetId int) (*AcquirableJobList, error) + + GetMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, lastMessageId int64, maxCapacity int) (*RunnerScaleSetMessage, error) + DeleteMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, messageId int64) error + + GenerateJitRunnerConfig(ctx context.Context, jitRunnerSetting *RunnerScaleSetJitRunnerSetting, scaleSetId int) (*RunnerScaleSetJitRunnerConfig, error) + + GetRunner(ctx context.Context, runnerId int64) (*RunnerReference, error) + GetRunnerByName(ctx context.Context, runnerName string) (*RunnerReference, error) + RemoveRunner(ctx context.Context, runnerId int64) error + + SetUserAgent(info UserAgentInfo) +} + +type Client struct { + *http.Client + + // lock for refreshing the ActionsServiceAdminToken and ActionsServiceAdminTokenExpiresAt + mu sync.Mutex + + // TODO: Convert to unexported fields once refactor of Listener is complete + ActionsServiceAdminToken string + ActionsServiceAdminTokenExpiresAt time.Time + ActionsServiceURL string + + retryMax int + retryWaitMax time.Duration + + creds *ActionsAuth + config *GitHubConfig + logger logr.Logger + userAgent UserAgentInfo + + rootCAs *x509.CertPool + tlsInsecureSkipVerify bool + + proxyFunc ProxyFunc +} + +type GitHubAppAuth struct { + // AppID is the ID or the Client ID of the application + AppID string + AppInstallationID int64 + AppPrivateKey string +} + +type ActionsAuth struct { + // GitHub App + AppCreds *GitHubAppAuth + + // GitHub PAT + Token string +} + +var _ ActionsService = &Client{} + +type ProxyFunc func(req *http.Request) (*url.URL, error) + +type ClientOption func(*Client) + +type UserAgentInfo struct { + // Version is the version of the controller + Version string + // CommitSHA is the git commit SHA of the controller + CommitSHA string + // ScaleSetID is the ID of the scale set + ScaleSetID int + // HasProxy is true if the controller is running behind a proxy + HasProxy bool + // Subsystem is the subsystem such as listener, controller, etc. + // Each system may pick its own subsystem name. + Subsystem string +} + +func (u UserAgentInfo) String() string { + scaleSetID := "NA" + if u.ScaleSetID > 0 { + scaleSetID = strconv.Itoa(u.ScaleSetID) + } + + proxy := "Proxy/disabled" + if u.HasProxy { + proxy = "Proxy/enabled" + } + + return fmt.Sprintf("actions-runner-controller/%s (%s; %s) ScaleSetID/%s (%s)", u.Version, u.CommitSHA, u.Subsystem, scaleSetID, proxy) +} + +func WithLogger(logger logr.Logger) ClientOption { + return func(c *Client) { + c.logger = logger + } +} + +func WithRetryMax(retryMax int) ClientOption { + return func(c *Client) { + c.retryMax = retryMax + } +} + +func WithRetryWaitMax(retryWaitMax time.Duration) ClientOption { + return func(c *Client) { + c.retryWaitMax = retryWaitMax + } +} + +func WithRootCAs(rootCAs *x509.CertPool) ClientOption { + return func(c *Client) { + c.rootCAs = rootCAs + } +} + +func WithoutTLSVerify() ClientOption { + return func(c *Client) { + c.tlsInsecureSkipVerify = true + } +} + +func WithProxy(proxyFunc ProxyFunc) ClientOption { + return func(c *Client) { + c.proxyFunc = proxyFunc + } +} + +func NewClient(githubConfigURL string, creds *ActionsAuth, options ...ClientOption) (*Client, error) { + config, err := ParseGitHubConfigFromURL(githubConfigURL) + if err != nil { + return nil, fmt.Errorf("failed to parse githubConfigURL: %w", err) + } + + ac := &Client{ + creds: creds, + config: config, + logger: logr.Discard(), + + // retryablehttp defaults + retryMax: 4, + retryWaitMax: 30 * time.Second, + userAgent: UserAgentInfo{ + Version: "NA", + CommitSHA: "NA", + ScaleSetID: 0, + }, + } + + for _, option := range options { + option(ac) + } + + retryClient := retryablehttp.NewClient() + + retryClient.RetryMax = ac.retryMax + retryClient.RetryWaitMax = ac.retryWaitMax + + retryClient.HTTPClient.Timeout = 5 * time.Minute // timeout must be > 1m to accomodate long polling + + transport, ok := retryClient.HTTPClient.Transport.(*http.Transport) + if !ok { + // this should always be true, because retryablehttp.NewClient() uses + // cleanhttp.DefaultPooledTransport() + return nil, fmt.Errorf("failed to get http transport from retryablehttp client") + } + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &tls.Config{} + } + + if ac.rootCAs != nil { + transport.TLSClientConfig.RootCAs = ac.rootCAs + } + + if ac.tlsInsecureSkipVerify { + transport.TLSClientConfig.InsecureSkipVerify = true + } + + transport.Proxy = ac.proxyFunc + + retryClient.HTTPClient.Transport = transport + ac.Client = retryClient.StandardClient() + + return ac, nil +} + +func (c *Client) SetUserAgent(info UserAgentInfo) { + c.userAgent = info +} + +// Identifier returns a string to help identify a client uniquely. +// This is used for caching client instances and understanding when a config +// change warrants creating a new client. Any changes to Client that would +// require a new client should be reflected here. +func (c *Client) Identifier() string { + identifier := fmt.Sprintf("configURL:%q,", c.config.ConfigURL.String()) + + if c.creds.Token != "" { + identifier += fmt.Sprintf("token:%q,", c.creds.Token) + } + + if c.creds.AppCreds != nil { + identifier += fmt.Sprintf( + "appID:%q,installationID:%q,key:%q", + c.creds.AppCreds.AppID, + c.creds.AppCreds.AppInstallationID, + c.creds.AppCreds.AppPrivateKey, + ) + } + + if c.rootCAs != nil { + // ignoring because this cert pool is intended not to come from SystemCertPool + // nolint:staticcheck + identifier += fmt.Sprintf("rootCAs:%q", c.rootCAs.Subjects()) + } + + return uuid.NewHash(sha256.New(), uuid.NameSpaceOID, []byte(identifier), 6).String() +} + +func (c *Client) Do(req *http.Request) (*http.Response, error) { + resp, err := c.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("client request failed: %w", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read the response body: %w", err) + } + err = resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("failed to close the response body: %w", err) + } + + body = trimByteOrderMark(body) + resp.Body = io.NopCloser(bytes.NewReader(body)) + return resp, nil +} + +func (c *Client) NewGitHubAPIRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + u := c.config.GitHubAPIURL(path) + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to create new GitHub API request: %w", err) + } + + req.Header.Set("User-Agent", c.userAgent.String()) + + return req, nil +} + +func (c *Client) NewActionsServiceRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + err := c.updateTokenIfNeeded(ctx) + if err != nil { + return nil, fmt.Errorf("failed to issue update token if needed: %w", err) + } + + parsedPath, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("failed to parse path %q: %w", path, err) + } + + urlString, err := url.JoinPath(c.ActionsServiceURL, parsedPath.Path) + if err != nil { + return nil, fmt.Errorf("failed to join path (actions_service_url=%q, parsedPath=%q): %w", c.ActionsServiceURL, parsedPath.Path, err) + } + + u, err := url.Parse(urlString) + if err != nil { + return nil, fmt.Errorf("failed to parse url string %q: %w", urlString, err) + } + + q := u.Query() + maps.Copy(q, parsedPath.Query()) + + if q.Get("api-version") == "" { + q.Set("api-version", "6.0-preview") + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to create new request with context: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.ActionsServiceAdminToken)) + req.Header.Set("User-Agent", c.userAgent.String()) + + return req, nil +} + +func (c *Client) GetRunnerScaleSet(ctx context.Context, runnerGroupId int, runnerScaleSetName string) (*RunnerScaleSet, error) { + path := fmt.Sprintf("/%s?runnerGroupId=%d&name=%s", scaleSetEndpoint, runnerGroupId, runnerScaleSetName) + req, err := c.NewActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, ParseActionsErrorFromResponse(resp) + } + + var runnerScaleSetList *runnerScaleSetsResponse + if err := json.NewDecoder(resp.Body).Decode(&runnerScaleSetList); err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + if runnerScaleSetList.Count == 0 { + return nil, nil + } + if runnerScaleSetList.Count > 1 { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: fmt.Errorf("multiple runner scale sets found with name %q", runnerScaleSetName), + } + } + + return &runnerScaleSetList.RunnerScaleSets[0], nil +} + +func (c *Client) GetRunnerScaleSetById(ctx context.Context, runnerScaleSetId int) (*RunnerScaleSet, error) { + path := fmt.Sprintf("/%s/%d", scaleSetEndpoint, runnerScaleSetId) + req, err := c.NewActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, ParseActionsErrorFromResponse(resp) + } + + var runnerScaleSet *RunnerScaleSet + if err := json.NewDecoder(resp.Body).Decode(&runnerScaleSet); err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + return runnerScaleSet, nil +} + +func (c *Client) GetRunnerGroupByName(ctx context.Context, runnerGroup string) (*RunnerGroup, error) { + path := fmt.Sprintf("/_apis/runtime/runnergroups/?groupName=%s", runnerGroup) + req, err := c.NewActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + return nil, fmt.Errorf("unexpected status code: %w", &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: errors.New(string(body)), + }) + } + + var runnerGroupList *RunnerGroupList + err = json.NewDecoder(resp.Body).Decode(&runnerGroupList) + if err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + + if runnerGroupList.Count == 0 { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: fmt.Errorf("no runner group found with name %q", runnerGroup), + } + } + + if runnerGroupList.Count > 1 { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: fmt.Errorf("multiple runner group found with name %q", runnerGroup), + } + } + + return &runnerGroupList.RunnerGroups[0], nil +} + +func (c *Client) CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) { + body, err := json.Marshal(runnerScaleSet) + if err != nil { + return nil, fmt.Errorf("failed to marshal runner scale set: %w", err) + } + + req, err := c.NewActionsServiceRequest(ctx, http.MethodPost, scaleSetEndpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, ParseActionsErrorFromResponse(resp) + } + var createdRunnerScaleSet *RunnerScaleSet + if err := json.NewDecoder(resp.Body).Decode(&createdRunnerScaleSet); err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + return createdRunnerScaleSet, nil +} + +func (c *Client) UpdateRunnerScaleSet(ctx context.Context, runnerScaleSetId int, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) { + path := fmt.Sprintf("%s/%d", scaleSetEndpoint, runnerScaleSetId) + + body, err := json.Marshal(runnerScaleSet) + if err != nil { + return nil, fmt.Errorf("failed to marshal runner scale set: %w", err) + } + + req, err := c.NewActionsServiceRequest(ctx, http.MethodPatch, path, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, ParseActionsErrorFromResponse(resp) + } + + var updatedRunnerScaleSet *RunnerScaleSet + if err := json.NewDecoder(resp.Body).Decode(&updatedRunnerScaleSet); err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + return updatedRunnerScaleSet, nil +} + +func (c *Client) DeleteRunnerScaleSet(ctx context.Context, runnerScaleSetId int) error { + path := fmt.Sprintf("/%s/%d", scaleSetEndpoint, runnerScaleSetId) + req, err := c.NewActionsServiceRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusNoContent { + return ParseActionsErrorFromResponse(resp) + } + + defer resp.Body.Close() + return nil +} + +func (c *Client) GetMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, lastMessageId int64, maxCapacity int) (*RunnerScaleSetMessage, error) { + u, err := url.Parse(messageQueueUrl) + if err != nil { + return nil, fmt.Errorf("failed to parse message queue url: %w", err) + } + + if lastMessageId > 0 { + q := u.Query() + q.Set("lastMessageId", strconv.FormatInt(lastMessageId, 10)) + u.RawQuery = q.Encode() + } + + if maxCapacity < 0 { + return nil, fmt.Errorf("maxCapacity must be greater than or equal to 0") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return nil, fmt.Errorf("failed to create new request with context: %w", err) + } + + req.Header.Set("Accept", "application/json; api-version=6.0-preview") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", messageQueueAccessToken)) + req.Header.Set("User-Agent", c.userAgent.String()) + req.Header.Set(HeaderScaleSetMaxCapacity, strconv.Itoa(maxCapacity)) + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode == http.StatusAccepted { + defer resp.Body.Close() + return nil, nil + } + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusUnauthorized { + return nil, ParseActionsErrorFromResponse(resp) + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + body = trimByteOrderMark(body) + if err != nil { + return nil, &ActionsError{ + ActivityID: resp.Header.Get(HeaderActionsActivityID), + StatusCode: resp.StatusCode, + Err: err, + } + } + return nil, &MessageQueueTokenExpiredError{ + activityID: resp.Header.Get(HeaderActionsActivityID), + statusCode: resp.StatusCode, + msg: string(body), + } + } + + var message *RunnerScaleSetMessage + if err := json.NewDecoder(resp.Body).Decode(&message); err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + return message, nil +} + +func (c *Client) DeleteMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, messageId int64) error { + u, err := url.Parse(messageQueueUrl) + if err != nil { + return fmt.Errorf("failed to parse message queue url: %w", err) + } + + u.Path = fmt.Sprintf("%s/%d", u.Path, messageId) + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, u.String(), nil) + if err != nil { + return fmt.Errorf("failed to create new request with context: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", messageQueueAccessToken)) + req.Header.Set("User-Agent", c.userAgent.String()) + + resp, err := c.Do(req) + if err != nil { + return fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusNoContent { + if resp.StatusCode != http.StatusUnauthorized { + return ParseActionsErrorFromResponse(resp) + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + body = trimByteOrderMark(body) + if err != nil { + return &ActionsError{ + ActivityID: resp.Header.Get(HeaderActionsActivityID), + StatusCode: resp.StatusCode, + Err: err, + } + } + return &MessageQueueTokenExpiredError{ + activityID: resp.Header.Get(HeaderActionsActivityID), + statusCode: resp.StatusCode, + msg: string(body), + } + } + return nil +} + +func (c *Client) CreateMessageSession(ctx context.Context, runnerScaleSetId int, owner string) (*RunnerScaleSetSession, error) { + path := fmt.Sprintf("/%s/%d/sessions", scaleSetEndpoint, runnerScaleSetId) + + newSession := &RunnerScaleSetSession{ + OwnerName: owner, + } + + requestData, err := json.Marshal(newSession) + if err != nil { + return nil, fmt.Errorf("failed to marshal new session: %w", err) + } + + createdSession := &RunnerScaleSetSession{} + + if err = c.doSessionRequest(ctx, http.MethodPost, path, bytes.NewBuffer(requestData), http.StatusOK, createdSession); err != nil { + return nil, fmt.Errorf("failed to do the session request: %w", err) + } + + return createdSession, nil +} + +func (c *Client) DeleteMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) error { + path := fmt.Sprintf("/%s/%d/sessions/%s", scaleSetEndpoint, runnerScaleSetId, sessionId.String()) + return c.doSessionRequest(ctx, http.MethodDelete, path, nil, http.StatusNoContent, nil) +} + +func (c *Client) RefreshMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) (*RunnerScaleSetSession, error) { + path := fmt.Sprintf("/%s/%d/sessions/%s", scaleSetEndpoint, runnerScaleSetId, sessionId.String()) + refreshedSession := &RunnerScaleSetSession{} + if err := c.doSessionRequest(ctx, http.MethodPatch, path, nil, http.StatusOK, refreshedSession); err != nil { + return nil, fmt.Errorf("failed to do the session request: %w", err) + } + return refreshedSession, nil +} + +func (c *Client) doSessionRequest(ctx context.Context, method, path string, requestData io.Reader, expectedResponseStatusCode int, responseUnmarshalTarget any) error { + req, err := c.NewActionsServiceRequest(ctx, method, path, requestData) + if err != nil { + return fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode == expectedResponseStatusCode { + if responseUnmarshalTarget == nil { + return nil + } + + if err := json.NewDecoder(resp.Body).Decode(responseUnmarshalTarget); err != nil { + return &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + + return nil + } + + if resp.StatusCode >= 400 && resp.StatusCode < 500 { + return ParseActionsErrorFromResponse(resp) + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + body = trimByteOrderMark(body) + if err != nil { + return &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + + return fmt.Errorf("unexpected status code: %w", &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: errors.New(string(body)), + }) +} + +func (c *Client) AcquireJobs(ctx context.Context, runnerScaleSetId int, messageQueueAccessToken string, requestIds []int64) ([]int64, error) { + u := fmt.Sprintf("%s/%s/%d/acquirejobs?api-version=6.0-preview", c.ActionsServiceURL, scaleSetEndpoint, runnerScaleSetId) + + body, err := json.Marshal(requestIds) + if err != nil { + return nil, fmt.Errorf("failed to marshal request ids: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create new request with context: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", messageQueueAccessToken)) + req.Header.Set("User-Agent", c.userAgent.String()) + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + if resp.StatusCode != http.StatusUnauthorized { + return nil, ParseActionsErrorFromResponse(resp) + } + + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + body = trimByteOrderMark(body) + if err != nil { + return nil, &ActionsError{ + ActivityID: resp.Header.Get(HeaderActionsActivityID), + StatusCode: resp.StatusCode, + Err: err, + } + } + + return nil, &MessageQueueTokenExpiredError{ + activityID: resp.Header.Get(HeaderActionsActivityID), + statusCode: resp.StatusCode, + msg: string(body), + } + } + + var acquiredJobs *Int64List + err = json.NewDecoder(resp.Body).Decode(&acquiredJobs) + if err != nil { + return nil, &ActionsError{ + ActivityID: resp.Header.Get(HeaderActionsActivityID), + StatusCode: resp.StatusCode, + Err: err, + } + } + + return acquiredJobs.Value, nil +} + +func (c *Client) GetAcquirableJobs(ctx context.Context, runnerScaleSetId int) (*AcquirableJobList, error) { + path := fmt.Sprintf("/%s/%d/acquirablejobs", scaleSetEndpoint, runnerScaleSetId) + + req, err := c.NewActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode == http.StatusNoContent { + defer resp.Body.Close() + return &AcquirableJobList{Count: 0, Jobs: []AcquirableJob{}}, nil + } + + if resp.StatusCode != http.StatusOK { + return nil, ParseActionsErrorFromResponse(resp) + } + + var acquirableJobList *AcquirableJobList + err = json.NewDecoder(resp.Body).Decode(&acquirableJobList) + if err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + + return acquirableJobList, nil +} + +func (c *Client) GenerateJitRunnerConfig(ctx context.Context, jitRunnerSetting *RunnerScaleSetJitRunnerSetting, scaleSetId int) (*RunnerScaleSetJitRunnerConfig, error) { + path := fmt.Sprintf("/%s/%d/generatejitconfig", scaleSetEndpoint, scaleSetId) + + body, err := json.Marshal(jitRunnerSetting) + if err != nil { + return nil, fmt.Errorf("failed to marshal runner settings: %w", err) + } + + req, err := c.NewActionsServiceRequest(ctx, http.MethodPost, path, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, ParseActionsErrorFromResponse(resp) + } + + var runnerJitConfig *RunnerScaleSetJitRunnerConfig + if err := json.NewDecoder(resp.Body).Decode(&runnerJitConfig); err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + return runnerJitConfig, nil +} + +func (c *Client) GetRunner(ctx context.Context, runnerId int64) (*RunnerReference, error) { + path := fmt.Sprintf("/%s/%d", runnerEndpoint, runnerId) + + req, err := c.NewActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, ParseActionsErrorFromResponse(resp) + } + + var runnerReference *RunnerReference + if err := json.NewDecoder(resp.Body).Decode(&runnerReference); err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + + return runnerReference, nil +} + +func (c *Client) GetRunnerByName(ctx context.Context, runnerName string) (*RunnerReference, error) { + path := fmt.Sprintf("/%s?agentName=%s", runnerEndpoint, runnerName) + + req, err := c.NewActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, ParseActionsErrorFromResponse(resp) + } + + var runnerList *RunnerReferenceList + if err := json.NewDecoder(resp.Body).Decode(&runnerList); err != nil { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: err, + } + } + + if runnerList.Count == 0 { + return nil, nil + } + + if runnerList.Count > 1 { + return nil, &ActionsError{ + StatusCode: resp.StatusCode, + ActivityID: resp.Header.Get(HeaderActionsActivityID), + Err: fmt.Errorf("multiple runner found with name %s", runnerName), + } + } + + return &runnerList.RunnerReferences[0], nil +} + +func (c *Client) RemoveRunner(ctx context.Context, runnerId int64) error { + path := fmt.Sprintf("/%s/%d", runnerEndpoint, runnerId) + + req, err := c.NewActionsServiceRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.Do(req) + if err != nil { + return fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusNoContent { + return ParseActionsErrorFromResponse(resp) + } + + defer resp.Body.Close() + return nil +} + +type registrationToken struct { + Token *string `json:"token,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +func (c *Client) getRunnerRegistrationToken(ctx context.Context) (*registrationToken, error) { + path, err := createRegistrationTokenPath(c.config) + if err != nil { + return nil, fmt.Errorf("failed to create registration token path: %w", err) + } + + var buf bytes.Buffer + req, err := c.NewGitHubAPIRequest(ctx, http.MethodPost, path, &buf) + if err != nil { + return nil, fmt.Errorf("failed to create new GitHub API request: %w", err) + } + + bearerToken := "" + + if c.creds.Token != "" { + bearerToken = fmt.Sprintf("Bearer %v", c.creds.Token) + } else { + accessToken, err := c.fetchAccessToken(ctx, c.config.ConfigURL.String(), c.creds.AppCreds) + if err != nil { + return nil, fmt.Errorf("failed to fetch access token: %w", err) + } + + bearerToken = fmt.Sprintf("Bearer %v", accessToken.Token) + } + + req.Header.Set("Content-Type", "application/vnd.github.v3+json") + req.Header.Set("Authorization", bearerToken) + + c.logger.Info("getting runner registration token", "registrationTokenURL", req.URL.String()) + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read the body: %w", err) + } + return nil, &GitHubAPIError{ + StatusCode: resp.StatusCode, + RequestID: resp.Header.Get(HeaderGitHubRequestID), + Err: errors.New(string(body)), + } + } + + var registrationToken *registrationToken + if err := json.NewDecoder(resp.Body).Decode(®istrationToken); err != nil { + return nil, &GitHubAPIError{ + StatusCode: resp.StatusCode, + RequestID: resp.Header.Get(HeaderGitHubRequestID), + Err: err, + } + } + + return registrationToken, nil +} + +// Format: https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app +type accessToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (c *Client) fetchAccessToken(ctx context.Context, gitHubConfigURL string, creds *GitHubAppAuth) (*accessToken, error) { + accessTokenJWT, err := createJWTForGitHubApp(creds) + if err != nil { + return nil, fmt.Errorf("failed to create JWT for GitHub app: %w", err) + } + + path := fmt.Sprintf("/app/installations/%v/access_tokens", creds.AppInstallationID) + req, err := c.NewGitHubAPIRequest(ctx, http.MethodPost, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new GitHub API request: %w", err) + } + + req.Header.Set("Content-Type", "application/vnd.github+json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessTokenJWT)) + + c.logger.Info("getting access token for GitHub App auth", "accessTokenURL", req.URL.String()) + + resp, err := c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + errMsg := fmt.Sprintf("failed to get access token for GitHub App auth (%v)", resp.Status) + if body, err := io.ReadAll(resp.Body); err == nil { + errMsg = fmt.Sprintf("%s: %s", errMsg, string(body)) + } + + return nil, &GitHubAPIError{ + StatusCode: resp.StatusCode, + RequestID: resp.Header.Get(HeaderGitHubRequestID), + Err: errors.New(errMsg), + } + } + + // Format: https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app + var accessToken *accessToken + if err = json.NewDecoder(resp.Body).Decode(&accessToken); err != nil { + return nil, &GitHubAPIError{ + StatusCode: resp.StatusCode, + RequestID: resp.Header.Get(HeaderGitHubRequestID), + Err: err, + } + } + return accessToken, nil +} + +type ActionsServiceAdminConnection struct { + ActionsServiceUrl *string `json:"url,omitempty"` + AdminToken *string `json:"token,omitempty"` +} + +func (c *Client) getActionsServiceAdminConnection(ctx context.Context, rt *registrationToken) (*ActionsServiceAdminConnection, error) { + path := "/actions/runner-registration" + + body := struct { + Url string `json:"url"` + RunnerEvent string `json:"runner_event"` + }{ + Url: c.config.ConfigURL.String(), + RunnerEvent: "register", + } + + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + + if err := enc.Encode(body); err != nil { + return nil, fmt.Errorf("failed to encode body: %w", err) + } + + req, err := c.NewGitHubAPIRequest(ctx, http.MethodPost, path, buf) + if err != nil { + return nil, fmt.Errorf("failed to create new GitHub API request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("RemoteAuth %s", *rt.Token)) + + c.logger.Info("getting Actions tenant URL and JWT", "registrationURL", req.URL.String()) + + var resp *http.Response + retry := 0 + for { + var err error + resp, err = c.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode <= 299 { + break + } + + var innerErr error + body, err := io.ReadAll(resp.Body) + if err != nil { + innerErr = err + } else { + innerErr = errors.New(string(body)) + } + + if resp.StatusCode != http.StatusUnauthorized && resp.StatusCode != http.StatusForbidden { + return nil, &GitHubAPIError{ + StatusCode: resp.StatusCode, + RequestID: resp.Header.Get(HeaderGitHubRequestID), + Err: innerErr, + } + } + + retry++ + if retry > 5 { + return nil, fmt.Errorf("unable to register runner after 3 retries: %w", &GitHubAPIError{ + StatusCode: resp.StatusCode, + RequestID: resp.Header.Get(HeaderGitHubRequestID), + Err: innerErr, + }) + } + // Add exponential backoff + jitter to avoid thundering herd + // This will generate a backoff schedule: + // 1: 1s + // 2: 3s + // 3: 4s + // 4: 8s + // 5: 17s + baseDelay := 500 * time.Millisecond + jitter := time.Duration(rand.Intn(1000)) + maxDelay := 20 * time.Second + delay := baseDelay*(1< maxDelay { + delay = maxDelay + } + + time.Sleep(delay) + } + + var actionsServiceAdminConnection *ActionsServiceAdminConnection + if err := json.NewDecoder(resp.Body).Decode(&actionsServiceAdminConnection); err != nil { + return nil, &GitHubAPIError{ + StatusCode: resp.StatusCode, + RequestID: resp.Header.Get(HeaderGitHubRequestID), + Err: err, + } + } + + return actionsServiceAdminConnection, nil +} + +func createRegistrationTokenPath(config *GitHubConfig) (string, error) { + switch config.Scope { + case GitHubScopeOrganization: + path := fmt.Sprintf("/orgs/%s/actions/runners/registration-token", config.Organization) + return path, nil + + case GitHubScopeEnterprise: + path := fmt.Sprintf("/enterprises/%s/actions/runners/registration-token", config.Enterprise) + return path, nil + + case GitHubScopeRepository: + path := fmt.Sprintf("/repos/%s/%s/actions/runners/registration-token", config.Organization, config.Repository) + return path, nil + + default: + return "", fmt.Errorf("unknown scope for config url: %s", config.ConfigURL) + } +} + +func createJWTForGitHubApp(appAuth *GitHubAppAuth) (string, error) { + // Encode as JWT + // See https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app + + // Going back in time a bit helps with clock skew. + issuedAt := time.Now().Add(-60 * time.Second) + // Max expiration date is 10 minutes. + expiresAt := issuedAt.Add(9 * time.Minute) + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(issuedAt), + ExpiresAt: jwt.NewNumericDate(expiresAt), + Issuer: appAuth.AppID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(appAuth.AppPrivateKey)) + if err != nil { + return "", fmt.Errorf("failed to parse RSA private key from PEM: %w", err) + } + + return token.SignedString(privateKey) +} + +// Returns slice of body without utf-8 byte order mark. +// If BOM does not exist body is returned unchanged. +func trimByteOrderMark(body []byte) []byte { + return bytes.TrimPrefix(body, []byte("\xef\xbb\xbf")) +} + +func actionsServiceAdminTokenExpiresAt(jwtToken string) (time.Time, error) { + type JwtClaims struct { + jwt.RegisteredClaims + } + token, _, err := jwt.NewParser().ParseUnverified(jwtToken, &JwtClaims{}) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse jwt token: %w", err) + } + + if claims, ok := token.Claims.(*JwtClaims); ok { + return claims.ExpiresAt.Time, nil + } + + return time.Time{}, fmt.Errorf("failed to parse token claims to get expire at") +} + +func (c *Client) updateTokenIfNeeded(ctx context.Context) error { + c.mu.Lock() + defer c.mu.Unlock() + + aboutToExpire := time.Now().Add(60 * time.Second).After(c.ActionsServiceAdminTokenExpiresAt) + if !aboutToExpire && !c.ActionsServiceAdminTokenExpiresAt.IsZero() { + return nil + } + + c.logger.Info("refreshing token", "githubConfigUrl", c.config.ConfigURL.String()) + rt, err := c.getRunnerRegistrationToken(ctx) + if err != nil { + return fmt.Errorf("failed to get runner registration token on refresh: %w", err) + } + + adminConnInfo, err := c.getActionsServiceAdminConnection(ctx, rt) + if err != nil { + return fmt.Errorf("failed to get actions service admin connection on refresh: %w", err) + } + + c.ActionsServiceURL = *adminConnInfo.ActionsServiceUrl + c.ActionsServiceAdminToken = *adminConnInfo.AdminToken + c.ActionsServiceAdminTokenExpiresAt, err = actionsServiceAdminTokenExpiresAt(*adminConnInfo.AdminToken) + if err != nil { + return fmt.Errorf("failed to get admin token expire at on refresh: %w", err) + } + + return nil +} diff --git a/client_generate_jit_test.go b/client_generate_jit_test.go new file mode 100644 index 0000000..05f59ed --- /dev/null +++ b/client_generate_jit_test.go @@ -0,0 +1,61 @@ +package scaleset_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/actions/scaleset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerateJitRunnerConfig(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("Get JIT Config for Runner", func(t *testing.T) { + want := &scaleset.RunnerScaleSetJitRunnerConfig{} + response := []byte(`{"count":1,"value":[{"id":1,"name":"scale-set-name"}]}`) + + runnerSettings := &scaleset.RunnerScaleSetJitRunnerSetting{} + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(response) + })) + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GenerateJitRunnerConfig(ctx, runnerSettings, 1) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + runnerSettings := &scaleset.RunnerScaleSetJitRunnerSetting{} + + retryMax := 1 + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(1), + scaleset.WithRetryWaitMax(1*time.Millisecond), + ) + require.NoError(t, err) + + _, err = client.GenerateJitRunnerConfig(ctx, runnerSettings, 1) + assert.NotNil(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} diff --git a/client_job_acquisition_test.go b/client_job_acquisition_test.go new file mode 100644 index 0000000..4e761e9 --- /dev/null +++ b/client_job_acquisition_test.go @@ -0,0 +1,171 @@ +package scaleset_test + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/actions/scaleset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAcquireJobs(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("Acquire Job", func(t *testing.T) { + want := []int64{1} + response := []byte(`{"value": [1]}`) + + session := &scaleset.RunnerScaleSetSession{ + RunnerScaleSet: &scaleset.RunnerScaleSet{Id: 1}, + MessageQueueAccessToken: "abc", + } + requestIDs := want + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/acquirablejobs") { + w.Write([]byte(`{"count": 1}`)) + return + } + + w.Write(response) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetAcquirableJobs(ctx, 1) + require.NoError(t, err) + + got, err := client.AcquireJobs(ctx, session.RunnerScaleSet.Id, session.MessageQueueAccessToken, requestIDs) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + session := &scaleset.RunnerScaleSetSession{ + RunnerScaleSet: &scaleset.RunnerScaleSet{Id: 1}, + MessageQueueAccessToken: "abc", + } + var requestIDs = []int64{1} + + retryMax := 1 + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/acquirablejobs") { + w.Write([]byte(`{"count": 1}`)) + return + } + + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(1*time.Millisecond), + ) + require.NoError(t, err) + + _, err = client.GetAcquirableJobs(ctx, 1) + require.NoError(t, err) + + _, err = client.AcquireJobs(context.Background(), session.RunnerScaleSet.Id, session.MessageQueueAccessToken, requestIDs) + assert.NotNil(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) + + t.Run("Should return MessageQueueTokenExpiredError when http error is not Unauthorized", func(t *testing.T) { + want := []int64{1} + + session := &scaleset.RunnerScaleSetSession{ + RunnerScaleSet: &scaleset.RunnerScaleSet{Id: 1}, + MessageQueueAccessToken: "abc", + } + requestIDs := want + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/acquirablejobs") { + w.Write([]byte(`{"count": 1}`)) + return + } + if r.Method == http.MethodPost { + http.Error(w, "Session expired", http.StatusUnauthorized) + return + } + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetAcquirableJobs(ctx, 1) + require.NoError(t, err) + + got, err := client.AcquireJobs(ctx, session.RunnerScaleSet.Id, session.MessageQueueAccessToken, requestIDs) + require.Error(t, err) + assert.Nil(t, got) + var expectedErr *scaleset.MessageQueueTokenExpiredError + assert.True(t, errors.As(err, &expectedErr)) + }) +} + +func TestGetAcquirableJobs(t *testing.T) { + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("Acquire Job", func(t *testing.T) { + want := &scaleset.AcquirableJobList{} + response := []byte(`{"count": 0}`) + + runnerScaleSet := &scaleset.RunnerScaleSet{Id: 1} + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(response) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetAcquirableJobs(context.Background(), runnerScaleSet.Id) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + runnerScaleSet := &scaleset.RunnerScaleSet{Id: 1} + + retryMax := 1 + + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(1*time.Millisecond), + ) + require.NoError(t, err) + + _, err = client.GetAcquirableJobs(context.Background(), runnerScaleSet.Id) + require.Error(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} diff --git a/client_proxy_test.go b/client_proxy_test.go new file mode 100644 index 0000000..8e81e1d --- /dev/null +++ b/client_proxy_test.go @@ -0,0 +1,39 @@ +package scaleset_test + +import ( + "net/http" + "net/url" + "testing" + + "github.com/actions/scaleset" + "github.com/actions/scaleset/testserver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/net/http/httpproxy" +) + +func TestClientProxy(t *testing.T) { + serverCalled := false + + proxy := testserver.New(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + serverCalled = true + })) + + proxyConfig := &httpproxy.Config{ + HTTPProxy: proxy.URL, + } + proxyFunc := func(req *http.Request) (*url.URL, error) { + return proxyConfig.ProxyFunc()(req.URL) + } + + c, err := scaleset.NewClient("http://github.com/org/repo", nil, scaleset.WithProxy(proxyFunc)) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodGet, "http://example.com", nil) + require.NoError(t, err) + + _, err = c.Do(req) + require.NoError(t, err) + + assert.True(t, serverCalled) +} diff --git a/client_runner_scale_set_message_test.go b/client_runner_scale_set_message_test.go new file mode 100644 index 0000000..2ed3254 --- /dev/null +++ b/client_runner_scale_set_message_test.go @@ -0,0 +1,248 @@ +package scaleset_test + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + "testing" + "time" + + "github.com/actions/scaleset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetMessage(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE" + runnerScaleSetMessage := &scaleset.RunnerScaleSetMessage{ + MessageId: 1, + MessageType: "rssType", + } + + t.Run("Get Runner Scale Set Message", func(t *testing.T) { + want := runnerScaleSetMessage + response := []byte(`{"messageId":1,"messageType":"rssType"}`) + s := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(response) + })) + + client, err := scaleset.NewClient(s.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetMessage(ctx, s.URL, token, 0, 10) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("GetMessage sets the last message id if not 0", func(t *testing.T) { + want := runnerScaleSetMessage + response := []byte(`{"messageId":1,"messageType":"rssType"}`) + s := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + assert.Equal(t, "1", q.Get("lastMessageId")) + w.Write(response) + })) + + client, err := scaleset.NewClient(s.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetMessage(ctx, s.URL, token, 1, 10) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + retryMax := 1 + + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(1*time.Millisecond), + ) + require.NoError(t, err) + + _, err = client.GetMessage(ctx, server.URL, token, 0, 10) + assert.NotNil(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) + + t.Run("Message token expired", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetMessage(ctx, server.URL, token, 0, 10) + require.NotNil(t, err) + + var expectedErr *scaleset.MessageQueueTokenExpiredError + require.True(t, errors.As(err, &expectedErr)) + }) + + t.Run("Status code not found", func(t *testing.T) { + want := scaleset.ActionsError{ + Err: errors.New("unknown exception"), + StatusCode: 404, + } + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetMessage(ctx, server.URL, token, 0, 10) + require.NotNil(t, err) + assert.Equal(t, want.Error(), err.Error()) + }) + + t.Run("Error when Content-Type is text/plain", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "text/plain") + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetMessage(ctx, server.URL, token, 0, 10) + assert.NotNil(t, err) + }) + + t.Run("Capacity error handling", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hc := r.Header.Get(scaleset.HeaderScaleSetMaxCapacity) + c, err := strconv.Atoi(hc) + require.NoError(t, err) + assert.GreaterOrEqual(t, c, 0) + + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "text/plain") + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetMessage(ctx, server.URL, token, 0, -1) + require.Error(t, err) + // Ensure we don't send requests with negative capacity + assert.False(t, errors.Is(err, &scaleset.ActionsError{})) + + _, err = client.GetMessage(ctx, server.URL, token, 0, 0) + assert.Error(t, err) + var expectedErr *scaleset.ActionsError + assert.ErrorAs(t, err, &expectedErr) + assert.Equal(t, http.StatusBadRequest, expectedErr.StatusCode) + }) +} + +func TestDeleteMessage(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE" + runnerScaleSetMessage := &scaleset.RunnerScaleSetMessage{ + MessageId: 1, + MessageType: "rssType", + } + + t.Run("Delete existing message", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + err = client.DeleteMessage(ctx, server.URL, token, runnerScaleSetMessage.MessageId) + assert.Nil(t, err) + }) + + t.Run("Message token expired", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + err = client.DeleteMessage(ctx, server.URL, token, 0) + require.NotNil(t, err) + var expectedErr *scaleset.MessageQueueTokenExpiredError + assert.True(t, errors.As(err, &expectedErr)) + }) + + t.Run("Error when Content-Type is text/plain", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "text/plain") + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + err = client.DeleteMessage(ctx, server.URL, token, runnerScaleSetMessage.MessageId) + require.NotNil(t, err) + var expectedErr *scaleset.ActionsError + assert.True(t, errors.As(err, &expectedErr)) + }, + ) + + t.Run("Default retries on server error", func(t *testing.T) { + actualRetry := 0 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + retryMax := 1 + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(1*time.Nanosecond), + ) + require.NoError(t, err) + err = client.DeleteMessage(ctx, server.URL, token, runnerScaleSetMessage.MessageId) + assert.NotNil(t, err) + expectedRetry := retryMax + 1 + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) + + t.Run("No message found", func(t *testing.T) { + want := (*scaleset.RunnerScaleSetMessage)(nil) + rsl, err := json.Marshal(want) + require.NoError(t, err) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(rsl) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + err = client.DeleteMessage(ctx, server.URL, token, runnerScaleSetMessage.MessageId+1) + var expectedErr *scaleset.ActionsError + require.True(t, errors.As(err, &expectedErr)) + }) +} diff --git a/client_runner_scale_set_session_test.go b/client_runner_scale_set_session_test.go new file mode 100644 index 0000000..19b2533 --- /dev/null +++ b/client_runner_scale_set_session_test.go @@ -0,0 +1,220 @@ +package scaleset_test + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/actions/scaleset" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const exampleRequestID = "5ddf2050-dae0-013c-9159-04421ad31b68" + +func TestCreateMessageSession(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("CreateMessageSession unmarshals correctly", func(t *testing.T) { + owner := "foo" + runnerScaleSet := scaleset.RunnerScaleSet{ + Id: 1, + Name: "ScaleSet", + CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), + RunnerSetting: scaleset.RunnerSetting{}, + } + + want := &scaleset.RunnerScaleSetSession{ + OwnerName: "foo", + RunnerScaleSet: &scaleset.RunnerScaleSet{ + Id: 1, + Name: "ScaleSet", + }, + MessageQueueUrl: "http://fake.scaleset.github.com/123", + MessageQueueAccessToken: "fake.jwt.here", + } + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + resp := []byte(`{ + "ownerName": "foo", + "runnerScaleSet": { + "id": 1, + "name": "ScaleSet" + }, + "messageQueueUrl": "http://fake.scaleset.github.com/123", + "messageQueueAccessToken": "fake.jwt.here" + }`) + w.Write(resp) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.CreateMessageSession(ctx, runnerScaleSet.Id, owner) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("CreateMessageSession unmarshals errors into ActionsError", func(t *testing.T) { + owner := "foo" + runnerScaleSet := scaleset.RunnerScaleSet{ + Id: 1, + Name: "ScaleSet", + CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), + RunnerSetting: scaleset.RunnerSetting{}, + } + + want := &scaleset.ActionsError{ + ActivityID: exampleRequestID, + StatusCode: http.StatusBadRequest, + Err: &scaleset.ActionsExceptionError{ + ExceptionName: "CSharpExceptionNameHere", + Message: "could not do something", + }, + } + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set(scaleset.HeaderActionsActivityID, exampleRequestID) + w.WriteHeader(http.StatusBadRequest) + resp := []byte(`{"typeName": "CSharpExceptionNameHere","message": "could not do something"}`) + w.Write(resp) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.CreateMessageSession(ctx, runnerScaleSet.Id, owner) + require.NotNil(t, err) + + errorTypeForComparison := &scaleset.ActionsError{} + assert.True( + t, + errors.As(err, &errorTypeForComparison), + "CreateMessageSession expected to be able to parse the error into ActionsError type: %v", + err, + ) + + assert.Equal(t, want, errorTypeForComparison) + }) + + t.Run("CreateMessageSession call is retried the correct amount of times", func(t *testing.T) { + owner := "foo" + runnerScaleSet := scaleset.RunnerScaleSet{ + Id: 1, + Name: "ScaleSet", + CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), + RunnerSetting: scaleset.RunnerSetting{}, + } + + gotRetries := 0 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + gotRetries++ + })) + + retryMax := 3 + retryWaitMax := 1 * time.Microsecond + + wantRetries := retryMax + 1 + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + _, err = client.CreateMessageSession(ctx, runnerScaleSet.Id, owner) + assert.NotNil(t, err) + assert.Equalf(t, gotRetries, wantRetries, "CreateMessageSession got unexpected retry count: got=%v, want=%v", gotRetries, wantRetries) + }) +} + +func TestDeleteMessageSession(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("DeleteMessageSession call is retried the correct amount of times", func(t *testing.T) { + runnerScaleSet := scaleset.RunnerScaleSet{ + Id: 1, + Name: "ScaleSet", + CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), + RunnerSetting: scaleset.RunnerSetting{}, + } + + gotRetries := 0 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + gotRetries++ + })) + + retryMax := 3 + retryWaitMax := 1 * time.Microsecond + + wantRetries := retryMax + 1 + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + sessionId := uuid.New() + + err = client.DeleteMessageSession(ctx, runnerScaleSet.Id, &sessionId) + assert.NotNil(t, err) + assert.Equalf(t, gotRetries, wantRetries, "CreateMessageSession got unexpected retry count: got=%v, want=%v", gotRetries, wantRetries) + }) +} + +func TestRefreshMessageSession(t *testing.T) { + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("RefreshMessageSession call is retried the correct amount of times", func(t *testing.T) { + runnerScaleSet := scaleset.RunnerScaleSet{ + Id: 1, + Name: "ScaleSet", + CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC), + RunnerSetting: scaleset.RunnerSetting{}, + } + + gotRetries := 0 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + gotRetries++ + })) + + retryMax := 3 + retryWaitMax := 1 * time.Microsecond + + wantRetries := retryMax + 1 + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + sessionId := uuid.New() + + _, err = client.RefreshMessageSession(context.Background(), runnerScaleSet.Id, &sessionId) + assert.NotNil(t, err) + assert.Equalf(t, gotRetries, wantRetries, "CreateMessageSession got unexpected retry count: got=%v, want=%v", gotRetries, wantRetries) + }) +} diff --git a/client_runner_scale_set_test.go b/client_runner_scale_set_test.go new file mode 100644 index 0000000..5cedcdf --- /dev/null +++ b/client_runner_scale_set_test.go @@ -0,0 +1,424 @@ +package scaleset_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "testing" + "time" + + "github.com/actions/scaleset" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetRunnerScaleSet(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + scaleSetName := "ScaleSet" + runnerScaleSet := scaleset.RunnerScaleSet{Id: 1, Name: scaleSetName} + + t.Run("Get existing scale set", func(t *testing.T) { + want := &runnerScaleSet + runnerScaleSetsResp := []byte(`{"count":1,"value":[{"id":1,"name":"ScaleSet"}]}`) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(runnerScaleSetsResp) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetRunnerScaleSet(ctx, 1, scaleSetName) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("GetRunnerScaleSet calls correct url", func(t *testing.T) { + runnerScaleSetsResp := []byte(`{"count":1,"value":[{"id":1,"name":"ScaleSet"}]}`) + url := url.URL{} + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(runnerScaleSetsResp) + url = *r.URL + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + require.NoError(t, err) + + expectedPath := "/tenant/123/_apis/runtime/runnerscalesets" + assert.Equal(t, expectedPath, url.Path) + assert.Equal(t, scaleSetName, url.Query().Get("name")) + assert.Equal(t, "6.0-preview", url.Query().Get("api-version")) + }) + + t.Run("Status code not found", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + assert.NotNil(t, err) + }) + + t.Run("Error when Content-Type is text/plain", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "text/plain") + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + assert.NotNil(t, err) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + actualRetry := 0 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + retryMax := 1 + retryWaitMax := 1 * time.Microsecond + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + assert.NotNil(t, err) + expectedRetry := retryMax + 1 + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) + + t.Run("RunnerScaleSet count is zero", func(t *testing.T) { + want := (*scaleset.RunnerScaleSet)(nil) + runnerScaleSetsResp := []byte(`{"count":0,"value":[{"id":1,"name":"ScaleSet"}]}`) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(runnerScaleSetsResp) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetRunnerScaleSet(ctx, 1, scaleSetName) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Multiple runner scale sets found", func(t *testing.T) { + reqID := uuid.NewString() + wantErr := &scaleset.ActionsError{ + StatusCode: http.StatusOK, + ActivityID: reqID, + Err: fmt.Errorf("multiple runner scale sets found with name %q", scaleSetName), + } + runnerScaleSetsResp := []byte(`{"count":2,"value":[{"id":1,"name":"ScaleSet"}]}`) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(scaleset.HeaderActionsActivityID, reqID) + w.Write(runnerScaleSetsResp) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + require.NotNil(t, err) + assert.Equal(t, wantErr.Error(), err.Error()) + }) +} + +func TestGetRunnerScaleSetById(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC) + runnerScaleSet := scaleset.RunnerScaleSet{Id: 1, Name: "ScaleSet", CreatedOn: scaleSetCreationDateTime, RunnerSetting: scaleset.RunnerSetting{}} + + t.Run("Get existing scale set by Id", func(t *testing.T) { + want := &runnerScaleSet + rsl, err := json.Marshal(want) + require.NoError(t, err) + sservere := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(rsl) + })) + + client, err := scaleset.NewClient(sservere.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetRunnerScaleSetById(ctx, runnerScaleSet.Id) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("GetRunnerScaleSetById calls correct url", func(t *testing.T) { + rsl, err := json.Marshal(&runnerScaleSet) + require.NoError(t, err) + + url := url.URL{} + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(rsl) + url = *r.URL + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSetById(ctx, runnerScaleSet.Id) + require.NoError(t, err) + + expectedPath := fmt.Sprintf("/tenant/123/_apis/runtime/runnerscalesets/%d", runnerScaleSet.Id) + assert.Equal(t, expectedPath, url.Path) + assert.Equal(t, "6.0-preview", url.Query().Get("api-version")) + }) + + t.Run("Status code not found", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSetById(ctx, runnerScaleSet.Id) + assert.NotNil(t, err) + }) + + t.Run("Error when Content-Type is text/plain", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "text/plain") + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSetById(ctx, runnerScaleSet.Id) + assert.NotNil(t, err) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + actualRetry := 0 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + retryMax := 1 + retryWaitMax := 1 * time.Microsecond + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSetById(ctx, runnerScaleSet.Id) + require.NotNil(t, err) + expectedRetry := retryMax + 1 + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) + + t.Run("No RunnerScaleSet found", func(t *testing.T) { + want := (*scaleset.RunnerScaleSet)(nil) + rsl, err := json.Marshal(want) + require.NoError(t, err) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(rsl) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetRunnerScaleSetById(ctx, runnerScaleSet.Id) + require.NoError(t, err) + assert.Equal(t, want, got) + }) +} + +func TestCreateRunnerScaleSet(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC) + runnerScaleSet := scaleset.RunnerScaleSet{Id: 1, Name: "ScaleSet", CreatedOn: scaleSetCreationDateTime, RunnerSetting: scaleset.RunnerSetting{}} + + t.Run("Create runner scale set", func(t *testing.T) { + want := &runnerScaleSet + rsl, err := json.Marshal(want) + require.NoError(t, err) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(rsl) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.CreateRunnerScaleSet(ctx, &runnerScaleSet) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("CreateRunnerScaleSet calls correct url", func(t *testing.T) { + rsl, err := json.Marshal(&runnerScaleSet) + require.NoError(t, err) + url := url.URL{} + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(rsl) + url = *r.URL + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.CreateRunnerScaleSet(ctx, &runnerScaleSet) + require.NoError(t, err) + + expectedPath := "/tenant/123/_apis/runtime/runnerscalesets" + assert.Equal(t, expectedPath, url.Path) + assert.Equal(t, "6.0-preview", url.Query().Get("api-version")) + }) + + t.Run("Error when Content-Type is text/plain", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + w.Header().Set("Content-Type", "text/plain") + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.CreateRunnerScaleSet(ctx, &runnerScaleSet) + require.NotNil(t, err) + var expectedErr *scaleset.ActionsError + assert.True(t, errors.As(err, &expectedErr)) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + actualRetry := 0 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + retryMax := 1 + retryWaitMax := 1 * time.Microsecond + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + _, err = client.CreateRunnerScaleSet(ctx, &runnerScaleSet) + require.NotNil(t, err) + expectedRetry := retryMax + 1 + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} + +func TestUpdateRunnerScaleSet(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC) + runnerScaleSet := scaleset.RunnerScaleSet{Id: 1, Name: "ScaleSet", RunnerGroupId: 1, RunnerGroupName: "group", CreatedOn: scaleSetCreationDateTime, RunnerSetting: scaleset.RunnerSetting{}} + + t.Run("Update runner scale set", func(t *testing.T) { + want := &runnerScaleSet + rsl, err := json.Marshal(want) + require.NoError(t, err) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(rsl) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.UpdateRunnerScaleSet(ctx, 1, &scaleset.RunnerScaleSet{RunnerGroupId: 1}) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("UpdateRunnerScaleSet calls correct url", func(t *testing.T) { + rsl, err := json.Marshal(&runnerScaleSet) + require.NoError(t, err) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/tenant/123/_apis/runtime/runnerscalesets/1" + assert.Equal(t, expectedPath, r.URL.Path) + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "6.0-preview", r.URL.Query().Get("api-version")) + + w.Write(rsl) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + _, err = client.UpdateRunnerScaleSet(ctx, 1, &runnerScaleSet) + require.NoError(t, err) + }) +} + +func TestDeleteRunnerScaleSet(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("Delete runner scale set", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + assert.Contains(t, r.URL.String(), "/_apis/runtime/runnerscalesets/10?api-version=6.0-preview") + w.WriteHeader(http.StatusNoContent) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + err = client.DeleteRunnerScaleSet(ctx, 10) + assert.NoError(t, err) + }) + + t.Run("Delete calls with error", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + assert.Contains(t, r.URL.String(), "/_apis/runtime/runnerscalesets/10?api-version=6.0-preview") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"message": "test error"}`)) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + err = client.DeleteRunnerScaleSet(ctx, 10) + assert.ErrorContains(t, err, "test error") + }) +} diff --git a/client_runner_test.go b/client_runner_test.go new file mode 100644 index 0000000..7f819a8 --- /dev/null +++ b/client_runner_test.go @@ -0,0 +1,218 @@ +package scaleset_test + +import ( + "context" + "net/http" + "testing" + "time" + + "github.com/actions/scaleset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetRunner(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("Get Runner", func(t *testing.T) { + var runnerID int64 = 1 + want := &scaleset.RunnerReference{ + Id: int(runnerID), + Name: "self-hosted-ubuntu", + } + response := []byte(`{"id": 1, "name": "self-hosted-ubuntu"}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetRunner(ctx, runnerID) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + var runnerID int64 = 1 + retryWaitMax := 1 * time.Millisecond + retryMax := 1 + + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth, scaleset.WithRetryMax(retryMax), scaleset.WithRetryWaitMax(retryWaitMax)) + require.NoError(t, err) + + _, err = client.GetRunner(ctx, runnerID) + require.Error(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} + +func TestGetRunnerByName(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("Get Runner by Name", func(t *testing.T) { + var runnerID int64 = 1 + var runnerName = "self-hosted-ubuntu" + want := &scaleset.RunnerReference{ + Id: int(runnerID), + Name: runnerName, + } + response := []byte(`{"count": 1, "value": [{"id": 1, "name": "self-hosted-ubuntu"}]}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetRunnerByName(ctx, runnerName) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Get Runner by name with not exist runner", func(t *testing.T) { + var runnerName = "self-hosted-ubuntu" + response := []byte(`{"count": 0, "value": []}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetRunnerByName(ctx, runnerName) + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + var runnerName = "self-hosted-ubuntu" + + retryWaitMax := 1 * time.Millisecond + retryMax := 1 + + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth, scaleset.WithRetryMax(retryMax), scaleset.WithRetryWaitMax(retryWaitMax)) + require.NoError(t, err) + + _, err = client.GetRunnerByName(ctx, runnerName) + require.Error(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} + +func TestDeleteRunner(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("Delete Runner", func(t *testing.T) { + var runnerID int64 = 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + err = client.RemoveRunner(ctx, runnerID) + assert.NoError(t, err) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + var runnerID int64 = 1 + + retryWaitMax := 1 * time.Millisecond + retryMax := 1 + + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := scaleset.NewClient( + server.configURLForOrg("my-org"), + auth, + scaleset.WithRetryMax(retryMax), + scaleset.WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + err = client.RemoveRunner(ctx, runnerID) + require.Error(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} + +func TestGetRunnerGroupByName(t *testing.T) { + ctx := context.Background() + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + t.Run("Get RunnerGroup by Name", func(t *testing.T) { + var runnerGroupID int64 = 1 + var runnerGroupName = "test-runner-group" + want := &scaleset.RunnerGroup{ + ID: runnerGroupID, + Name: runnerGroupName, + } + response := []byte(`{"count": 1, "value": [{"id": 1, "name": "test-runner-group"}]}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetRunnerGroupByName(ctx, runnerGroupName) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Get RunnerGroup by name with not exist runner group", func(t *testing.T) { + var runnerGroupName = "test-runner-group" + response := []byte(`{"count": 0, "value": []}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := scaleset.NewClient(server.configURLForOrg("my-org"), auth) + require.NoError(t, err) + + got, err := client.GetRunnerGroupByName(ctx, runnerGroupName) + assert.ErrorContains(t, err, "no runner group found with name") + assert.Nil(t, got) + }) +} diff --git a/client_tls_test.go b/client_tls_test.go new file mode 100644 index 0000000..18de8fb --- /dev/null +++ b/client_tls_test.go @@ -0,0 +1,178 @@ +package scaleset_test + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/actions/scaleset" + "github.com/golang-jwt/jwt/v4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServerWithSelfSignedCertificates(t *testing.T) { + ctx := context.Background() + // this handler is a very very barebones replica of actions api + // used during the creation of a a new client + var u string + h := func(w http.ResponseWriter, r *http.Request) { + // handle get registration token + if strings.HasSuffix(r.URL.Path, "/runners/registration-token") { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"token":"token"}`)) + return + } + + // handle getActionsServiceAdminConnection + if strings.HasSuffix(r.URL.Path, "/actions/runner-registration") { + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Minute)), + Issuer: "123", + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(samplePrivateKey)) + require.NoError(t, err) + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + w.Write([]byte(`{"url":"` + u + `","token":"` + tokenString + `"}`)) + return + } + + // default happy response for RemoveRunner + w.WriteHeader(http.StatusNoContent) + } + + certPath := filepath.Join("testdata", "server.crt") + keyPath := filepath.Join("testdata", "server.key") + + t.Run("client without ca certs", func(t *testing.T) { + server := startNewTLSTestServer(t, certPath, keyPath, http.HandlerFunc(h)) + u = server.URL + configURL := server.URL + "/my-org" + + auth := &scaleset.ActionsAuth{ + Token: "token", + } + client, err := scaleset.NewClient(configURL, auth) + require.NoError(t, err) + require.NotNil(t, client) + + err = client.RemoveRunner(ctx, 1) + require.NotNil(t, err) + + if runtime.GOOS == "linux" { + assert.True(t, errors.As(err, &x509.UnknownAuthorityError{})) + } + + // on macOS we only get an untyped error from the system verifying the + // certificate + if runtime.GOOS == "darwin" { + assert.True(t, strings.HasSuffix(err.Error(), "certificate is not trusted")) + } + }) + + t.Run("client with ca certs", func(t *testing.T) { + server := startNewTLSTestServer( + t, + certPath, + keyPath, + http.HandlerFunc(h), + ) + u = server.URL + configURL := server.URL + "/my-org" + + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + cert, err := os.ReadFile(filepath.Join("testdata", "rootCA.crt")) + require.NoError(t, err) + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(cert)) + + client, err := scaleset.NewClient( + configURL, + auth, + scaleset.WithRootCAs(pool), + ) + require.NoError(t, err) + assert.NotNil(t, client) + + err = client.RemoveRunner(ctx, 1) + assert.NoError(t, err) + }) + + t.Run("client with ca chain certs", func(t *testing.T) { + server := startNewTLSTestServer( + t, + filepath.Join("testdata", "leaf.crt"), + filepath.Join("testdata", "leaf.key"), + http.HandlerFunc(h), + ) + u = server.URL + configURL := server.URL + "/my-org" + + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + cert, err := os.ReadFile(filepath.Join("testdata", "intermediate.crt")) + require.NoError(t, err) + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(cert)) + + client, err := scaleset.NewClient( + configURL, + auth, + scaleset.WithRootCAs(pool), + scaleset.WithRetryMax(0), + ) + require.NoError(t, err) + require.NotNil(t, client) + + err = client.RemoveRunner(ctx, 1) + assert.NoError(t, err) + }) + + t.Run("client skipping tls verification", func(t *testing.T) { + server := startNewTLSTestServer(t, certPath, keyPath, http.HandlerFunc(h)) + configURL := server.URL + "/my-org" + + auth := &scaleset.ActionsAuth{ + Token: "token", + } + + client, err := scaleset.NewClient(configURL, auth, scaleset.WithoutTLSVerify()) + require.NoError(t, err) + assert.NotNil(t, client) + }) +} + +func startNewTLSTestServer(t *testing.T, certPath, keyPath string, handler http.Handler) *httptest.Server { + server := httptest.NewUnstartedServer(handler) + t.Cleanup(func() { + server.Close() + }) + + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + require.NoError(t, err) + + server.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + server.StartTLS() + + return server +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..53a089a --- /dev/null +++ b/config.go @@ -0,0 +1,109 @@ +package scaleset + +import ( + "fmt" + "net/url" + "os" + "strings" +) + +var ErrInvalidGitHubConfigURL = fmt.Errorf("invalid config URL, should point to an enterprise, org, or repository") + +type GitHubScope int + +const ( + GitHubScopeUnknown GitHubScope = iota + GitHubScopeEnterprise + GitHubScopeOrganization + GitHubScopeRepository +) + +type GitHubConfig struct { + ConfigURL *url.URL + Scope GitHubScope + + Enterprise string + Organization string + Repository string + + IsHosted bool +} + +func ParseGitHubConfigFromURL(in string) (*GitHubConfig, error) { + u, err := url.Parse(strings.Trim(in, "/")) + if err != nil { + return nil, err + } + + isHosted := isHostedGitHubURL(u) + + configURL := &GitHubConfig{ + ConfigURL: u, + IsHosted: isHosted, + } + + invalidURLError := fmt.Errorf("%q: %w", u.String(), ErrInvalidGitHubConfigURL) + + pathParts := strings.Split(strings.Trim(u.Path, "/"), "/") + + switch len(pathParts) { + case 1: // Organization + if pathParts[0] == "" { + return nil, invalidURLError + } + + configURL.Scope = GitHubScopeOrganization + configURL.Organization = pathParts[0] + + case 2: // Repository or enterprise + if strings.ToLower(pathParts[0]) == "enterprises" { + configURL.Scope = GitHubScopeEnterprise + configURL.Enterprise = pathParts[1] + break + } + + configURL.Scope = GitHubScopeRepository + configURL.Organization = pathParts[0] + configURL.Repository = pathParts[1] + default: + return nil, invalidURLError + } + + return configURL, nil +} + +func (c *GitHubConfig) GitHubAPIURL(path string) *url.URL { + result := &url.URL{ + Scheme: c.ConfigURL.Scheme, + Host: c.ConfigURL.Host, // default for Enterprise mode + Path: "/api/v3", // default for Enterprise mode + } + + isHosted := isHostedGitHubURL(c.ConfigURL) + + if isHosted { + result.Host = fmt.Sprintf("api.%s", c.ConfigURL.Host) + result.Path = "" + + if strings.EqualFold("www.github.com", c.ConfigURL.Host) { + // re-routing www.github.com to api.github.com + result.Host = "api.github.com" + } + } + + result.Path += path + + return result +} + +func isHostedGitHubURL(u *url.URL) bool { + _, forceGhes := os.LookupEnv("GITHUB_ACTIONS_FORCE_GHES") + if forceGhes { + return false + } + + return strings.EqualFold(u.Host, "github.com") || + strings.EqualFold(u.Host, "www.github.com") || + strings.EqualFold(u.Host, "github.localhost") || + strings.HasSuffix(u.Host, ".ghe.com") +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..190a1eb --- /dev/null +++ b/config_test.go @@ -0,0 +1,196 @@ +package scaleset_test + +import ( + "errors" + "net/url" + "os" + "strings" + "testing" + + "github.com/actions/scaleset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGitHubConfig(t *testing.T) { + t.Run("when given a valid URL", func(t *testing.T) { + tests := []struct { + configURL string + expected *scaleset.GitHubConfig + }{ + { + configURL: "https://github.com/org/repo", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeRepository, + Enterprise: "", + Organization: "org", + Repository: "repo", + IsHosted: true, + }, + }, + { + configURL: "https://github.com/org/repo/", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeRepository, + Enterprise: "", + Organization: "org", + Repository: "repo", + IsHosted: true, + }, + }, + { + configURL: "https://github.com/org", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeOrganization, + Enterprise: "", + Organization: "org", + Repository: "", + IsHosted: true, + }, + }, + { + configURL: "https://github.com/enterprises/my-enterprise", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeEnterprise, + Enterprise: "my-enterprise", + Organization: "", + Repository: "", + IsHosted: true, + }, + }, + { + configURL: "https://github.com/enterprises/my-enterprise/", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeEnterprise, + Enterprise: "my-enterprise", + Organization: "", + Repository: "", + IsHosted: true, + }, + }, + { + configURL: "https://www.github.com/org", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeOrganization, + Enterprise: "", + Organization: "org", + Repository: "", + IsHosted: true, + }, + }, + { + configURL: "https://www.github.com/org/", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeOrganization, + Enterprise: "", + Organization: "org", + Repository: "", + IsHosted: true, + }, + }, + { + configURL: "https://github.localhost/org", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeOrganization, + Enterprise: "", + Organization: "org", + Repository: "", + IsHosted: true, + }, + }, + { + configURL: "https://my-ghes.com/org", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeOrganization, + Enterprise: "", + Organization: "org", + Repository: "", + IsHosted: false, + }, + }, + { + configURL: "https://my-ghes.com/org/", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeOrganization, + Enterprise: "", + Organization: "org", + Repository: "", + IsHosted: false, + }, + }, + { + configURL: "https://my-ghes.ghe.com/org/", + expected: &scaleset.GitHubConfig{ + Scope: scaleset.GitHubScopeOrganization, + Enterprise: "", + Organization: "org", + Repository: "", + IsHosted: true, + }, + }, + } + + for _, test := range tests { + t.Run(test.configURL, func(t *testing.T) { + parsedURL, err := url.Parse(strings.Trim(test.configURL, "/")) + require.NoError(t, err) + test.expected.ConfigURL = parsedURL + + cfg, err := scaleset.ParseGitHubConfigFromURL(test.configURL) + require.NoError(t, err) + assert.Equal(t, test.expected, cfg) + }) + } + }) + + t.Run("when given an invalid URL", func(t *testing.T) { + invalidURLs := []string{ + "https://github.com/", + "https://github.com", + "https://github.com/some/random/path", + } + + for _, u := range invalidURLs { + _, err := scaleset.ParseGitHubConfigFromURL(u) + require.Error(t, err) + assert.True(t, errors.Is(err, scaleset.ErrInvalidGitHubConfigURL)) + } + }) +} + +func TestGitHubConfig_GitHubAPIURL(t *testing.T) { + t.Run("when hosted", func(t *testing.T) { + config, err := scaleset.ParseGitHubConfigFromURL("https://github.com/org/repo") + require.NoError(t, err) + assert.True(t, config.IsHosted) + + result := config.GitHubAPIURL("/some/path") + assert.Equal(t, "https://api.github.com/some/path", result.String()) + }) + t.Run("when hosted with ghe.com", func(t *testing.T) { + config, err := scaleset.ParseGitHubConfigFromURL("https://github.ghe.com/org/repo") + require.NoError(t, err) + assert.True(t, config.IsHosted) + + result := config.GitHubAPIURL("/some/path") + assert.Equal(t, "https://api.github.ghe.com/some/path", result.String()) + }) + t.Run("when not hosted", func(t *testing.T) { + config, err := scaleset.ParseGitHubConfigFromURL("https://ghes.com/org/repo") + require.NoError(t, err) + assert.False(t, config.IsHosted) + + result := config.GitHubAPIURL("/some/path") + assert.Equal(t, "https://ghes.com/api/v3/some/path", result.String()) + }) + t.Run("when not hosted with ghe.com", func(t *testing.T) { + os.Setenv("GITHUB_ACTIONS_FORCE_GHES", "1") + defer os.Unsetenv("GITHUB_ACTIONS_FORCE_GHES") + config, err := scaleset.ParseGitHubConfigFromURL("https://test.ghe.com/org/repo") + require.NoError(t, err) + assert.False(t, config.IsHosted) + + result := config.GitHubAPIURL("/some/path") + assert.Equal(t, "https://test.ghe.com/api/v3/some/path", result.String()) + }) +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..ed12bcc --- /dev/null +++ b/errors.go @@ -0,0 +1,125 @@ +package scaleset + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +// Header names for request IDs +const ( + HeaderActionsActivityID = "ActivityId" + HeaderGitHubRequestID = "X-GitHub-Request-Id" +) + +type GitHubAPIError struct { + StatusCode int + RequestID string + Err error +} + +func (e *GitHubAPIError) Error() string { + return fmt.Sprintf("github api error: StatusCode %d, RequestID %q: %v", e.StatusCode, e.RequestID, e.Err) +} + +func (e *GitHubAPIError) Unwrap() error { + return e.Err +} + +type ActionsError struct { + ActivityID string + StatusCode int + Err error +} + +func (e *ActionsError) Error() string { + return fmt.Sprintf("actions error: StatusCode %d, AcivityId %q: %v", e.StatusCode, e.ActivityID, e.Err) +} + +func (e *ActionsError) Unwrap() error { + return e.Err +} + +func (e *ActionsError) IsException(target string) bool { + if ex, ok := e.Err.(*ActionsExceptionError); ok { + return strings.Contains(ex.ExceptionName, target) + } + return false +} + +type ActionsExceptionError struct { + ExceptionName string `json:"typeName,omitempty"` + Message string `json:"message,omitempty"` +} + +func (e *ActionsExceptionError) Error() string { + return fmt.Sprintf("%s: %s", e.ExceptionName, e.Message) +} + +func ParseActionsErrorFromResponse(response *http.Response) error { + if response.ContentLength == 0 { + return &ActionsError{ + ActivityID: response.Header.Get(HeaderActionsActivityID), + StatusCode: response.StatusCode, + Err: errors.New("unknown exception"), + } + } + + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + return &ActionsError{ + ActivityID: response.Header.Get(HeaderActionsActivityID), + StatusCode: response.StatusCode, + Err: err, + } + } + + body = trimByteOrderMark(body) + contentType, ok := response.Header["Content-Type"] + if ok && len(contentType) > 0 && strings.Contains(contentType[0], "text/plain") { + message := string(body) + return &ActionsError{ + ActivityID: response.Header.Get(HeaderActionsActivityID), + StatusCode: response.StatusCode, + Err: errors.New(message), + } + } + + var exception ActionsExceptionError + if err := json.Unmarshal(body, &exception); err != nil { + return &ActionsError{ + ActivityID: response.Header.Get(HeaderActionsActivityID), + StatusCode: response.StatusCode, + Err: err, + } + } + + return &ActionsError{ + ActivityID: response.Header.Get(HeaderActionsActivityID), + StatusCode: response.StatusCode, + Err: &exception, + } +} + +type MessageQueueTokenExpiredError struct { + activityID string + statusCode int + msg string +} + +func (e *MessageQueueTokenExpiredError) Error() string { + return fmt.Sprintf("MessageQueueTokenExpiredError: AcivityId %q, StatusCode %d: %s", e.activityID, e.statusCode, e.msg) +} + +type HttpClientSideError struct { + msg string + Code int +} + +func (e *HttpClientSideError) Error() string { + return e.msg +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..5314c2d --- /dev/null +++ b/errors_test.go @@ -0,0 +1,206 @@ +package scaleset_test + +import ( + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/actions/scaleset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionsError(t *testing.T) { + t.Run("contains the status code, activity ID, and error", func(t *testing.T) { + err := &scaleset.ActionsError{ + ActivityID: "activity-id", + StatusCode: 404, + Err: errors.New("example error description"), + } + + s := err.Error() + assert.Contains(t, s, "StatusCode 404") + assert.Contains(t, s, "AcivityId \"activity-id\"") + assert.Contains(t, s, "example error description") + }) + + t.Run("unwraps the error", func(t *testing.T) { + err := &scaleset.ActionsError{ + ActivityID: "activity-id", + StatusCode: 404, + Err: &scaleset.ActionsExceptionError{ + ExceptionName: "exception-name", + Message: "example error message", + }, + } + + assert.Equal(t, err.Unwrap(), err.Err) + }) + + t.Run("is exception is ok", func(t *testing.T) { + err := &scaleset.ActionsError{ + ActivityID: "activity-id", + StatusCode: 404, + Err: &scaleset.ActionsExceptionError{ + ExceptionName: "exception-name", + Message: "example error message", + }, + } + + var exception *scaleset.ActionsExceptionError + assert.True(t, errors.As(err, &exception)) + + assert.True(t, err.IsException("exception-name")) + }) + + t.Run("is exception is not ok", func(t *testing.T) { + tt := map[string]*scaleset.ActionsError{ + "not an exception": { + ActivityID: "activity-id", + StatusCode: 404, + Err: errors.New("example error description"), + }, + "not target exception": { + ActivityID: "activity-id", + StatusCode: 404, + Err: &scaleset.ActionsExceptionError{ + ExceptionName: "exception-name", + Message: "example error message", + }, + }, + } + + targetException := "target-exception" + for name, err := range tt { + t.Run(name, func(t *testing.T) { + assert.False(t, err.IsException(targetException)) + }) + } + }) +} + +func TestActionsExceptionError(t *testing.T) { + t.Run("contains the exception name and message", func(t *testing.T) { + err := &scaleset.ActionsExceptionError{ + ExceptionName: "exception-name", + Message: "example error message", + } + + s := err.Error() + assert.Contains(t, s, "exception-name") + assert.Contains(t, s, "example error message") + }) +} + +func TestGitHubAPIError(t *testing.T) { + t.Run("contains the status code, request ID, and error", func(t *testing.T) { + err := &scaleset.GitHubAPIError{ + StatusCode: 404, + RequestID: "request-id", + Err: errors.New("example error description"), + } + + s := err.Error() + assert.Contains(t, s, "StatusCode 404") + assert.Contains(t, s, "RequestID \"request-id\"") + assert.Contains(t, s, "example error description") + }) + + t.Run("unwraps the error", func(t *testing.T) { + err := &scaleset.GitHubAPIError{ + StatusCode: 404, + RequestID: "request-id", + Err: errors.New("example error description"), + } + + assert.Equal(t, err.Unwrap(), err.Err) + }) +} + +func ParseActionsErrorFromResponse(t *testing.T) { + t.Run("empty content length", func(t *testing.T) { + response := &http.Response{ + ContentLength: 0, + Header: http.Header{ + scaleset.HeaderActionsActivityID: []string{"activity-id"}, + }, + StatusCode: 404, + } + + err := scaleset.ParseActionsErrorFromResponse(response) + require.Error(t, err) + assert.Equal(t, err.(*scaleset.ActionsError).ActivityID, "activity-id") + assert.Equal(t, err.(*scaleset.ActionsError).StatusCode, 404) + assert.Equal(t, err.(*scaleset.ActionsError).Err.Error(), "unknown exception") + }) + + t.Run("contains text plain error", func(t *testing.T) { + errorMessage := "example error message" + response := &http.Response{ + ContentLength: int64(len(errorMessage)), + Header: http.Header{ + scaleset.HeaderActionsActivityID: []string{"activity-id"}, + "Content-Type": []string{"text/plain"}, + }, + StatusCode: 404, + Body: io.NopCloser(strings.NewReader(errorMessage)), + } + + err := scaleset.ParseActionsErrorFromResponse(response) + require.Error(t, err) + var actionsError *scaleset.ActionsError + assert.ErrorAs(t, err, &actionsError) + assert.Equal(t, actionsError.ActivityID, "activity-id") + assert.Equal(t, actionsError.StatusCode, 404) + assert.Equal(t, actionsError.Err.Error(), errorMessage) + }) + + t.Run("contains json error", func(t *testing.T) { + errorMessage := `{"typeName":"exception-name","message":"example error message"}` + response := &http.Response{ + ContentLength: int64(len(errorMessage)), + Header: http.Header{ + scaleset.HeaderActionsActivityID: []string{"activity-id"}, + "Content-Type": []string{"application/json"}, + }, + StatusCode: 404, + Body: io.NopCloser(strings.NewReader(errorMessage)), + } + + err := scaleset.ParseActionsErrorFromResponse(response) + require.Error(t, err) + var actionsError *scaleset.ActionsError + assert.ErrorAs(t, err, &actionsError) + assert.Equal(t, actionsError.ActivityID, "activity-id") + assert.Equal(t, actionsError.StatusCode, 404) + + inner, ok := actionsError.Err.(*scaleset.ActionsExceptionError) + require.True(t, ok) + assert.Equal(t, inner.ExceptionName, "exception-name") + assert.Equal(t, inner.Message, "example error message") + }) + + t.Run("wrapped exception error", func(t *testing.T) { + errorMessage := `{"typeName":"exception-name","message":"example error message"}` + response := &http.Response{ + ContentLength: int64(len(errorMessage)), + Header: http.Header{ + scaleset.HeaderActionsActivityID: []string{"activity-id"}, + "Content-Type": []string{"application/json"}, + }, + StatusCode: 404, + Body: io.NopCloser(strings.NewReader(errorMessage)), + } + + err := scaleset.ParseActionsErrorFromResponse(response) + require.Error(t, err) + + var actionsExceptionError *scaleset.ActionsExceptionError + assert.ErrorAs(t, err, &actionsExceptionError) + + assert.Equal(t, actionsExceptionError.ExceptionName, "exception-name") + assert.Equal(t, actionsExceptionError.Message, "example error message") + }) +} diff --git a/github_api_request_test.go b/github_api_request_test.go new file mode 100644 index 0000000..1032a70 --- /dev/null +++ b/github_api_request_test.go @@ -0,0 +1,248 @@ +package scaleset_test + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" + + "github.com/actions/scaleset" + "github.com/actions/scaleset/testserver" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var testUserAgent = scaleset.UserAgentInfo{ + Version: "test", + CommitSHA: "test", + ScaleSetID: 1, +} + +func TestNewGitHubAPIRequest(t *testing.T) { + ctx := context.Background() + + t.Run("uses the right host/path prefix", func(t *testing.T) { + scenarios := []struct { + configURL string + path string + expected string + }{ + { + configURL: "https://github.com/org/repo", + path: "/app/installations/123/access_tokens", + expected: "https://api.github.com/app/installations/123/access_tokens", + }, + { + configURL: "https://www.github.com/org/repo", + path: "/app/installations/123/access_tokens", + expected: "https://api.github.com/app/installations/123/access_tokens", + }, + { + configURL: "http://github.localhost/org/repo", + path: "/app/installations/123/access_tokens", + expected: "http://api.github.localhost/app/installations/123/access_tokens", + }, + { + configURL: "https://my-instance.com/org/repo", + path: "/app/installations/123/access_tokens", + expected: "https://my-instance.com/api/v3/app/installations/123/access_tokens", + }, + { + configURL: "http://localhost/org/repo", + path: "/app/installations/123/access_tokens", + expected: "http://localhost/api/v3/app/installations/123/access_tokens", + }, + } + + for _, scenario := range scenarios { + client, err := scaleset.NewClient(scenario.configURL, nil) + require.NoError(t, err) + + req, err := client.NewGitHubAPIRequest(ctx, http.MethodGet, scenario.path, nil) + require.NoError(t, err) + assert.Equal(t, scenario.expected, req.URL.String()) + } + }) + + t.Run("sets user agent header if present", func(t *testing.T) { + client, err := scaleset.NewClient("http://localhost/my-org", nil) + require.NoError(t, err) + + client.SetUserAgent(testUserAgent) + + req, err := client.NewGitHubAPIRequest(ctx, http.MethodGet, "/app/installations/123/access_tokens", nil) + require.NoError(t, err) + + assert.Equal(t, testUserAgent.String(), req.Header.Get("User-Agent")) + }) + + t.Run("sets the body we pass", func(t *testing.T) { + client, err := scaleset.NewClient("http://localhost/my-org", nil) + require.NoError(t, err) + + req, err := client.NewGitHubAPIRequest( + ctx, + http.MethodGet, + "/app/installations/123/access_tokens", + strings.NewReader("the-body"), + ) + require.NoError(t, err) + + b, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, "the-body", string(b)) + }) +} + +func TestNewActionsServiceRequest(t *testing.T) { + ctx := context.Background() + defaultCreds := &scaleset.ActionsAuth{Token: "token"} + + t.Run("manages authentication", func(t *testing.T) { + t.Run("client is brand new", func(t *testing.T) { + token := defaultActionsToken(t) + server := testserver.New(t, nil, testserver.WithActionsToken(token)) + + client, err := scaleset.NewClient(server.ConfigURLForOrg("my-org"), defaultCreds) + require.NoError(t, err) + + req, err := client.NewActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.NoError(t, err) + + assert.Equal(t, "Bearer "+token, req.Header.Get("Authorization")) + }) + + t.Run("admin token is about to expire", func(t *testing.T) { + newToken := defaultActionsToken(t) + server := testserver.New(t, nil, testserver.WithActionsToken(newToken)) + + client, err := scaleset.NewClient(server.ConfigURLForOrg("my-org"), defaultCreds) + require.NoError(t, err) + client.ActionsServiceAdminToken = "expiring-token" + client.ActionsServiceAdminTokenExpiresAt = time.Now().Add(59 * time.Second) + + req, err := client.NewActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.NoError(t, err) + + assert.Equal(t, "Bearer "+newToken, req.Header.Get("Authorization")) + }) + + t.Run("admin token refresh failure", func(t *testing.T) { + newToken := defaultActionsToken(t) + errMessage := `{"message":"test"}` + unauthorizedHandler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(errMessage)) + } + server := testserver.New( + t, + nil, + testserver.WithActionsToken("random-token"), + testserver.WithActionsToken(newToken), + testserver.WithActionsRegistrationTokenHandler(unauthorizedHandler), + ) + client, err := scaleset.NewClient(server.ConfigURLForOrg("my-org"), defaultCreds) + require.NoError(t, err) + expiringToken := "expiring-token" + expiresAt := time.Now().Add(59 * time.Second) + client.ActionsServiceAdminToken = expiringToken + client.ActionsServiceAdminTokenExpiresAt = expiresAt + _, err = client.NewActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), errMessage) + assert.Equal(t, client.ActionsServiceAdminToken, expiringToken) + assert.Equal(t, client.ActionsServiceAdminTokenExpiresAt, expiresAt) + }) + + t.Run("admin token refresh retry", func(t *testing.T) { + newToken := defaultActionsToken(t) + errMessage := `{"message":"test"}` + + srv := "http://github.com/my-org" + resp := &scaleset.ActionsServiceAdminConnection{ + AdminToken: &newToken, + ActionsServiceUrl: &srv, + } + failures := 0 + unauthorizedHandler := func(w http.ResponseWriter, r *http.Request) { + if failures < 5 { + failures++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(errMessage)) + return + } + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + } + server := testserver.New(t, nil, testserver.WithActionsToken("random-token"), testserver.WithActionsToken(newToken), testserver.WithActionsRegistrationTokenHandler(unauthorizedHandler)) + client, err := scaleset.NewClient(server.ConfigURLForOrg("my-org"), defaultCreds) + require.NoError(t, err) + expiringToken := "expiring-token" + expiresAt := time.Now().Add(59 * time.Second) + client.ActionsServiceAdminToken = expiringToken + client.ActionsServiceAdminTokenExpiresAt = expiresAt + + _, err = client.NewActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.NoError(t, err) + assert.Equal(t, client.ActionsServiceAdminToken, newToken) + assert.Equal(t, client.ActionsServiceURL, srv) + assert.NotEqual(t, client.ActionsServiceAdminTokenExpiresAt, expiresAt) + }) + + t.Run("token is currently valid", func(t *testing.T) { + tokenThatShouldNotBeFetched := defaultActionsToken(t) + server := testserver.New(t, nil, testserver.WithActionsToken(tokenThatShouldNotBeFetched)) + + client, err := scaleset.NewClient(server.ConfigURLForOrg("my-org"), defaultCreds) + require.NoError(t, err) + client.ActionsServiceAdminToken = "healthy-token" + client.ActionsServiceAdminTokenExpiresAt = time.Now().Add(1 * time.Hour) + + req, err := client.NewActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.NoError(t, err) + + assert.Equal(t, "Bearer healthy-token", req.Header.Get("Authorization")) + }) + }) + + t.Run("builds the right URL including api version", func(t *testing.T) { + server := testserver.New(t, nil) + + client, err := scaleset.NewClient(server.ConfigURLForOrg("my-org"), defaultCreds) + require.NoError(t, err) + + req, err := client.NewActionsServiceRequest(ctx, http.MethodGet, "/my/path?name=banana", nil) + require.NoError(t, err) + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + result := req.URL + assert.Equal(t, serverURL.Host, result.Host) + assert.Equal(t, "/tenant/123/my/path", result.Path) + assert.Equal(t, "banana", result.Query().Get("name")) + assert.Equal(t, "6.0-preview", result.Query().Get("api-version")) + }) + + t.Run("populates header", func(t *testing.T) { + server := testserver.New(t, nil) + + client, err := scaleset.NewClient(server.ConfigURLForOrg("my-org"), defaultCreds) + require.NoError(t, err) + + client.SetUserAgent(testUserAgent) + + req, err := client.NewActionsServiceRequest(ctx, http.MethodGet, "/my/path", nil) + require.NoError(t, err) + + assert.Equal(t, testUserAgent.String(), req.Header.Get("User-Agent")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..32d7129 --- /dev/null +++ b/go.mod @@ -0,0 +1,29 @@ +module github.com/actions/scaleset + +go 1.25.3 + +require ( + github.com/go-logr/logr v1.4.3 + github.com/golang-jwt/jwt/v4 v4.5.2 + github.com/google/uuid v1.6.0 + github.com/hashicorp/go-retryablehttp v0.7.8 + github.com/onsi/ginkgo/v2 v2.27.2 + github.com/stretchr/testify v1.11.1 + golang.org/x/net v0.46.0 +) + +require ( + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect + golang.org/x/mod v0.28.0 // indirect + golang.org/x/sync v0.17.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.0 // indirect + golang.org/x/tools v0.37.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c69531a --- /dev/null +++ b/go.sum @@ -0,0 +1,87 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= +golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= +google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/identifier_test.go b/identifier_test.go new file mode 100644 index 0000000..06e2e5d --- /dev/null +++ b/identifier_test.go @@ -0,0 +1,158 @@ +package scaleset_test + +import ( + "crypto/x509" + "os" + "path/filepath" + "testing" + + "github.com/actions/scaleset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_Identifier(t *testing.T) { + t.Run("configURL changes", func(t *testing.T) { + scenarios := []struct { + name string + url string + }{ + { + name: "url of a different repo", + url: "https://github.com/org/repo2", + }, + { + name: "url of an org", + url: "https://github.com/org", + }, + { + name: "url of an enterprise", + url: "https://github.com/enterprises/my-enterprise", + }, + { + name: "url of a self-hosted github", + url: "https://selfhosted.com/org/repo", + }, + } + + configURL := "https://github.com/org/repo" + defaultCreds := &scaleset.ActionsAuth{ + Token: "token", + } + oldClient, err := scaleset.NewClient(configURL, defaultCreds) + require.NoError(t, err) + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + newClient, err := scaleset.NewClient(scenario.url, defaultCreds) + require.NoError(t, err) + assert.NotEqual(t, oldClient.Identifier(), newClient.Identifier()) + }) + } + }) + + t.Run("credentials change", func(t *testing.T) { + defaultTokenCreds := &scaleset.ActionsAuth{ + Token: "token", + } + defaultAppCreds := &scaleset.ActionsAuth{ + AppCreds: &scaleset.GitHubAppAuth{ + AppID: "123", + AppInstallationID: 123, + AppPrivateKey: "private key", + }, + } + + scenarios := []struct { + name string + old *scaleset.ActionsAuth + new *scaleset.ActionsAuth + }{ + { + name: "different token", + old: defaultTokenCreds, + new: &scaleset.ActionsAuth{ + Token: "new token", + }, + }, + { + name: "changing from token to github app", + old: defaultTokenCreds, + new: defaultAppCreds, + }, + { + name: "changing from github app to token", + old: defaultAppCreds, + new: defaultTokenCreds, + }, + { + name: "different github app", + old: defaultAppCreds, + new: &scaleset.ActionsAuth{ + AppCreds: &scaleset.GitHubAppAuth{ + AppID: "456", + AppInstallationID: 456, + AppPrivateKey: "new private key", + }, + }, + }, + } + + defaultConfigURL := "https://github.com/org/repo" + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + oldClient, err := scaleset.NewClient(defaultConfigURL, scenario.old) + require.NoError(t, err) + + newClient, err := scaleset.NewClient(defaultConfigURL, scenario.new) + require.NoError(t, err) + assert.NotEqual(t, oldClient.Identifier(), newClient.Identifier()) + }) + } + }) + + t.Run("changes in TLS config", func(t *testing.T) { + configURL := "https://github.com/org/repo" + defaultCreds := &scaleset.ActionsAuth{ + Token: "token", + } + + noTlS, err := scaleset.NewClient(configURL, defaultCreds) + require.NoError(t, err) + + poolFromCert := func(t *testing.T, path string) *x509.CertPool { + t.Helper() + f, err := os.ReadFile(path) + require.NoError(t, err) + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(f)) + return pool + } + + root, err := scaleset.NewClient( + configURL, + defaultCreds, + scaleset.WithRootCAs(poolFromCert(t, filepath.Join("testdata", "rootCA.crt"))), + ) + require.NoError(t, err) + + chain, err := scaleset.NewClient( + configURL, + defaultCreds, + scaleset.WithRootCAs(poolFromCert(t, filepath.Join("testdata", "intermediate.crt"))), + ) + require.NoError(t, err) + + clients := []*scaleset.Client{ + noTlS, + root, + chain, + } + identifiers := map[string]struct{}{} + for _, client := range clients { + identifiers[client.Identifier()] = struct{}{} + } + assert.Len(t, identifiers, len(clients), "all clients should have a unique identifier") + }) +} diff --git a/mock_ActionsService.go b/mock_ActionsService.go new file mode 100644 index 0000000..f762f27 --- /dev/null +++ b/mock_ActionsService.go @@ -0,0 +1,428 @@ +// Code generated by mockery v2.36.1. DO NOT EDIT. + +package scaleset + +import ( + context "context" + + uuid "github.com/google/uuid" + mock "github.com/stretchr/testify/mock" +) + +// MockActionsService is an autogenerated mock type for the ActionsService type +type MockActionsService struct { + mock.Mock +} + +// AcquireJobs provides a mock function with given fields: ctx, runnerScaleSetId, messageQueueAccessToken, requestIds +func (_m *MockActionsService) AcquireJobs(ctx context.Context, runnerScaleSetId int, messageQueueAccessToken string, requestIds []int64) ([]int64, error) { + ret := _m.Called(ctx, runnerScaleSetId, messageQueueAccessToken, requestIds) + + var r0 []int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int, string, []int64) ([]int64, error)); ok { + return rf(ctx, runnerScaleSetId, messageQueueAccessToken, requestIds) + } + if rf, ok := ret.Get(0).(func(context.Context, int, string, []int64) []int64); ok { + r0 = rf(ctx, runnerScaleSetId, messageQueueAccessToken, requestIds) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int64) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int, string, []int64) error); ok { + r1 = rf(ctx, runnerScaleSetId, messageQueueAccessToken, requestIds) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CreateMessageSession provides a mock function with given fields: ctx, runnerScaleSetId, owner +func (_m *MockActionsService) CreateMessageSession(ctx context.Context, runnerScaleSetId int, owner string) (*RunnerScaleSetSession, error) { + ret := _m.Called(ctx, runnerScaleSetId, owner) + + var r0 *RunnerScaleSetSession + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int, string) (*RunnerScaleSetSession, error)); ok { + return rf(ctx, runnerScaleSetId, owner) + } + if rf, ok := ret.Get(0).(func(context.Context, int, string) *RunnerScaleSetSession); ok { + r0 = rf(ctx, runnerScaleSetId, owner) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerScaleSetSession) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int, string) error); ok { + r1 = rf(ctx, runnerScaleSetId, owner) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// CreateRunnerScaleSet provides a mock function with given fields: ctx, runnerScaleSet +func (_m *MockActionsService) CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) { + ret := _m.Called(ctx, runnerScaleSet) + + var r0 *RunnerScaleSet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *RunnerScaleSet) (*RunnerScaleSet, error)); ok { + return rf(ctx, runnerScaleSet) + } + if rf, ok := ret.Get(0).(func(context.Context, *RunnerScaleSet) *RunnerScaleSet); ok { + r0 = rf(ctx, runnerScaleSet) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerScaleSet) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *RunnerScaleSet) error); ok { + r1 = rf(ctx, runnerScaleSet) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// DeleteMessage provides a mock function with given fields: ctx, messageQueueUrl, messageQueueAccessToken, messageId +func (_m *MockActionsService) DeleteMessage(ctx context.Context, messageQueueUrl string, messageQueueAccessToken string, messageId int64) error { + ret := _m.Called(ctx, messageQueueUrl, messageQueueAccessToken, messageId) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) error); ok { + r0 = rf(ctx, messageQueueUrl, messageQueueAccessToken, messageId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteMessageSession provides a mock function with given fields: ctx, runnerScaleSetId, sessionId +func (_m *MockActionsService) DeleteMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) error { + ret := _m.Called(ctx, runnerScaleSetId, sessionId) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, *uuid.UUID) error); ok { + r0 = rf(ctx, runnerScaleSetId, sessionId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteRunnerScaleSet provides a mock function with given fields: ctx, runnerScaleSetId +func (_m *MockActionsService) DeleteRunnerScaleSet(ctx context.Context, runnerScaleSetId int) error { + ret := _m.Called(ctx, runnerScaleSetId) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int) error); ok { + r0 = rf(ctx, runnerScaleSetId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GenerateJitRunnerConfig provides a mock function with given fields: ctx, jitRunnerSetting, scaleSetId +func (_m *MockActionsService) GenerateJitRunnerConfig(ctx context.Context, jitRunnerSetting *RunnerScaleSetJitRunnerSetting, scaleSetId int) (*RunnerScaleSetJitRunnerConfig, error) { + ret := _m.Called(ctx, jitRunnerSetting, scaleSetId) + + var r0 *RunnerScaleSetJitRunnerConfig + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *RunnerScaleSetJitRunnerSetting, int) (*RunnerScaleSetJitRunnerConfig, error)); ok { + return rf(ctx, jitRunnerSetting, scaleSetId) + } + if rf, ok := ret.Get(0).(func(context.Context, *RunnerScaleSetJitRunnerSetting, int) *RunnerScaleSetJitRunnerConfig); ok { + r0 = rf(ctx, jitRunnerSetting, scaleSetId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerScaleSetJitRunnerConfig) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *RunnerScaleSetJitRunnerSetting, int) error); ok { + r1 = rf(ctx, jitRunnerSetting, scaleSetId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetAcquirableJobs provides a mock function with given fields: ctx, runnerScaleSetId +func (_m *MockActionsService) GetAcquirableJobs(ctx context.Context, runnerScaleSetId int) (*AcquirableJobList, error) { + ret := _m.Called(ctx, runnerScaleSetId) + + var r0 *AcquirableJobList + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int) (*AcquirableJobList, error)); ok { + return rf(ctx, runnerScaleSetId) + } + if rf, ok := ret.Get(0).(func(context.Context, int) *AcquirableJobList); ok { + r0 = rf(ctx, runnerScaleSetId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*AcquirableJobList) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, runnerScaleSetId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetMessage provides a mock function with given fields: ctx, messageQueueUrl, messageQueueAccessToken, lastMessageId, maxCapacity +func (_m *MockActionsService) GetMessage(ctx context.Context, messageQueueUrl string, messageQueueAccessToken string, lastMessageId int64, maxCapacity int) (*RunnerScaleSetMessage, error) { + ret := _m.Called(ctx, messageQueueUrl, messageQueueAccessToken, lastMessageId, maxCapacity) + + var r0 *RunnerScaleSetMessage + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64, int) (*RunnerScaleSetMessage, error)); ok { + return rf(ctx, messageQueueUrl, messageQueueAccessToken, lastMessageId, maxCapacity) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string, int64, int) *RunnerScaleSetMessage); ok { + r0 = rf(ctx, messageQueueUrl, messageQueueAccessToken, lastMessageId, maxCapacity) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerScaleSetMessage) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string, int64, int) error); ok { + r1 = rf(ctx, messageQueueUrl, messageQueueAccessToken, lastMessageId, maxCapacity) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRunner provides a mock function with given fields: ctx, runnerId +func (_m *MockActionsService) GetRunner(ctx context.Context, runnerId int64) (*RunnerReference, error) { + ret := _m.Called(ctx, runnerId) + + var r0 *RunnerReference + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64) (*RunnerReference, error)); ok { + return rf(ctx, runnerId) + } + if rf, ok := ret.Get(0).(func(context.Context, int64) *RunnerReference); ok { + r0 = rf(ctx, runnerId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerReference) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok { + r1 = rf(ctx, runnerId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRunnerByName provides a mock function with given fields: ctx, runnerName +func (_m *MockActionsService) GetRunnerByName(ctx context.Context, runnerName string) (*RunnerReference, error) { + ret := _m.Called(ctx, runnerName) + + var r0 *RunnerReference + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*RunnerReference, error)); ok { + return rf(ctx, runnerName) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *RunnerReference); ok { + r0 = rf(ctx, runnerName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerReference) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, runnerName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRunnerGroupByName provides a mock function with given fields: ctx, runnerGroup +func (_m *MockActionsService) GetRunnerGroupByName(ctx context.Context, runnerGroup string) (*RunnerGroup, error) { + ret := _m.Called(ctx, runnerGroup) + + var r0 *RunnerGroup + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (*RunnerGroup, error)); ok { + return rf(ctx, runnerGroup) + } + if rf, ok := ret.Get(0).(func(context.Context, string) *RunnerGroup); ok { + r0 = rf(ctx, runnerGroup) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerGroup) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, runnerGroup) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRunnerScaleSet provides a mock function with given fields: ctx, runnerGroupId, runnerScaleSetName +func (_m *MockActionsService) GetRunnerScaleSet(ctx context.Context, runnerGroupId int, runnerScaleSetName string) (*RunnerScaleSet, error) { + ret := _m.Called(ctx, runnerGroupId, runnerScaleSetName) + + var r0 *RunnerScaleSet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int, string) (*RunnerScaleSet, error)); ok { + return rf(ctx, runnerGroupId, runnerScaleSetName) + } + if rf, ok := ret.Get(0).(func(context.Context, int, string) *RunnerScaleSet); ok { + r0 = rf(ctx, runnerGroupId, runnerScaleSetName) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerScaleSet) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int, string) error); ok { + r1 = rf(ctx, runnerGroupId, runnerScaleSetName) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// GetRunnerScaleSetById provides a mock function with given fields: ctx, runnerScaleSetId +func (_m *MockActionsService) GetRunnerScaleSetById(ctx context.Context, runnerScaleSetId int) (*RunnerScaleSet, error) { + ret := _m.Called(ctx, runnerScaleSetId) + + var r0 *RunnerScaleSet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int) (*RunnerScaleSet, error)); ok { + return rf(ctx, runnerScaleSetId) + } + if rf, ok := ret.Get(0).(func(context.Context, int) *RunnerScaleSet); ok { + r0 = rf(ctx, runnerScaleSetId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerScaleSet) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, runnerScaleSetId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RefreshMessageSession provides a mock function with given fields: ctx, runnerScaleSetId, sessionId +func (_m *MockActionsService) RefreshMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) (*RunnerScaleSetSession, error) { + ret := _m.Called(ctx, runnerScaleSetId, sessionId) + + var r0 *RunnerScaleSetSession + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int, *uuid.UUID) (*RunnerScaleSetSession, error)); ok { + return rf(ctx, runnerScaleSetId, sessionId) + } + if rf, ok := ret.Get(0).(func(context.Context, int, *uuid.UUID) *RunnerScaleSetSession); ok { + r0 = rf(ctx, runnerScaleSetId, sessionId) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerScaleSetSession) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int, *uuid.UUID) error); ok { + r1 = rf(ctx, runnerScaleSetId, sessionId) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RemoveRunner provides a mock function with given fields: ctx, runnerId +func (_m *MockActionsService) RemoveRunner(ctx context.Context, runnerId int64) error { + ret := _m.Called(ctx, runnerId) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, runnerId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetUserAgent provides a mock function with given fields: info +func (_m *MockActionsService) SetUserAgent(info UserAgentInfo) { + _m.Called(info) +} + +// UpdateRunnerScaleSet provides a mock function with given fields: ctx, runnerScaleSetId, runnerScaleSet +func (_m *MockActionsService) UpdateRunnerScaleSet(ctx context.Context, runnerScaleSetId int, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) { + ret := _m.Called(ctx, runnerScaleSetId, runnerScaleSet) + + var r0 *RunnerScaleSet + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int, *RunnerScaleSet) (*RunnerScaleSet, error)); ok { + return rf(ctx, runnerScaleSetId, runnerScaleSet) + } + if rf, ok := ret.Get(0).(func(context.Context, int, *RunnerScaleSet) *RunnerScaleSet); ok { + r0 = rf(ctx, runnerScaleSetId, runnerScaleSet) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerScaleSet) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int, *RunnerScaleSet) error); ok { + r1 = rf(ctx, runnerScaleSetId, runnerScaleSet) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewMockActionsService creates a new instance of MockActionsService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockActionsService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockActionsService { + mock := &MockActionsService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/mock_SessionService.go b/mock_SessionService.go new file mode 100644 index 0000000..75164ba --- /dev/null +++ b/mock_SessionService.go @@ -0,0 +1,108 @@ +// Code generated by mockery v2.36.1. DO NOT EDIT. + +package scaleset + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" +) + +// MockSessionService is an autogenerated mock type for the SessionService type +type MockSessionService struct { + mock.Mock +} + +// AcquireJobs provides a mock function with given fields: ctx, requestIds +func (_m *MockSessionService) AcquireJobs(ctx context.Context, requestIds []int64) ([]int64, error) { + ret := _m.Called(ctx, requestIds) + + var r0 []int64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, []int64) ([]int64, error)); ok { + return rf(ctx, requestIds) + } + if rf, ok := ret.Get(0).(func(context.Context, []int64) []int64); ok { + r0 = rf(ctx, requestIds) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]int64) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []int64) error); ok { + r1 = rf(ctx, requestIds) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Close provides a mock function with given fields: +func (_m *MockSessionService) Close() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// DeleteMessage provides a mock function with given fields: ctx, messageId +func (_m *MockSessionService) DeleteMessage(ctx context.Context, messageId int64) error { + ret := _m.Called(ctx, messageId) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok { + r0 = rf(ctx, messageId) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// GetMessage provides a mock function with given fields: ctx, lastMessageId, maxCapacity +func (_m *MockSessionService) GetMessage(ctx context.Context, lastMessageId int64, maxCapacity int) (*RunnerScaleSetMessage, error) { + ret := _m.Called(ctx, lastMessageId, maxCapacity) + + var r0 *RunnerScaleSetMessage + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, int) (*RunnerScaleSetMessage, error)); ok { + return rf(ctx, lastMessageId, maxCapacity) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, int) *RunnerScaleSetMessage); ok { + r0 = rf(ctx, lastMessageId, maxCapacity) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*RunnerScaleSetMessage) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, int) error); ok { + r1 = rf(ctx, lastMessageId, maxCapacity) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewMockSessionService creates a new instance of MockSessionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockSessionService(t interface { + mock.TestingT + Cleanup(func()) +}) *MockSessionService { + mock := &MockSessionService{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/sessionservice.go b/sessionservice.go new file mode 100644 index 0000000..0e044a3 --- /dev/null +++ b/sessionservice.go @@ -0,0 +1,14 @@ +package scaleset + +import ( + "context" + "io" +) + +//go:generate mockery --inpackage --name=SessionService +type SessionService interface { + GetMessage(ctx context.Context, lastMessageId int64, maxCapacity int) (*RunnerScaleSetMessage, error) + DeleteMessage(ctx context.Context, messageId int64) error + AcquireJobs(ctx context.Context, requestIds []int64) ([]int64, error) + io.Closer +} diff --git a/testdata/generate.sh b/testdata/generate.sh new file mode 100644 index 0000000..7790be4 --- /dev/null +++ b/testdata/generate.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Generate Root CA +openssl genrsa -out rootCA.key 2048 +openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 1024 -out rootCA.crt -subj "/CN=Test Root CA" \ + -addext "basicConstraints = critical, CA:TRUE" \ + -addext "keyUsage = critical, keyCertSign, cRLSign" + +# Generate Intermediate Certificate +openssl genrsa -out intermediate.key 2048 +openssl req -new -key intermediate.key -out intermediate.csr -subj "/CN=Test Intermediate CA" +openssl x509 -req -in intermediate.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial -out intermediate.crt -days 1000 -sha256 \ + -extfile <(echo -e "basicConstraints = critical, CA:TRUE, pathlen:0\nkeyUsage = critical, keyCertSign, cRLSign") + +# Generate Leaf Certificate +openssl genrsa -out leaf.key 2048 +openssl req -new -key leaf.key -out leaf.csr -subj "/CN=localhost" \ + -addext "subjectAltName = IP:127.0.0.1" +openssl x509 -req -in leaf.csr -CA intermediate.crt -CAkey intermediate.key -CAcreateserial -out leaf.crt -days 500 -sha256 \ + -extfile <(echo -e "authorityKeyIdentifier=keyid,issuer\nbasicConstraints=CA:FALSE\nkeyUsage = digitalSignature, keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1") + +# Generate Leaf Certificate +openssl genrsa -out server.key 2048 +openssl req -new -key server.key -out server.csr -subj "/CN=localhost" \ + -addext "subjectAltName = IP:127.0.0.1" +openssl x509 -req -in server.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial -out server.crt -days 500 -sha256 \ + -extfile <(echo -e "authorityKeyIdentifier=keyid,issuer\nbasicConstraints=CA:FALSE\nkeyUsage = digitalSignature, keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1") + +rm rootCA.key intermediate.key *.csr *.srl diff --git a/testdata/intermediate.crt b/testdata/intermediate.crt new file mode 100644 index 0000000..476da8e --- /dev/null +++ b/testdata/intermediate.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKjCCAhKgAwIBAgIUQr7R8yN5+2and6ucUOPF6oIbD44wDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MDIyODEyMDEzMFoXDTI3 +MTEyNTEyMDEzMFowHzEdMBsGA1UEAwwUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDOGvN95wCkYO35qyJnf/RwTiDb +oEVaefKnZZny1JrO34MFjlAz8C/P5WwxNUzzbQLTPh5iTqFRU+vis6HPvV0HJEoI +wTfgBCZxcdY8fEIY96FGHLju3PzfxeJaVHyi+2cTtzU+oNp4OFF8huApjYXjaV4y +pAirPbiiP/cgtcT4L5WErQi0aGZkq+1YqY2duNFNIGPTEcXV4iN4IhuD9dpqdKFg +H0wmZDgH+VE/5ACXovU8j5cxCKOJGxTVMKVZlvxPH3w69Z85x3o5AAnyxwo8E2zo +TC1FJ1eFLsmYLZki6cGBzSkIl5QlLGHakWYh+JLu/pkfTL8t+AkY3hZJM96ZAgMB +AAGjZjBkMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1Ud +DgQWBBSmAyntm95+KoyL3ffLBXZKSpk1VTAfBgNVHSMEGDAWgBR93+rQFjh+RUFX +df4dbNcfS2hbTzANBgkqhkiG9w0BAQsFAAOCAQEABopVFLGQf/LFH+OKVCOT8FCC +y/+o1B/U5jXVvbfwlSGScaiJGQ94FsuH59XJCGySQj77ZVTeElBtntoLXmOCFjyF +jKHCDfUpB4nzeqNMvTDzuoYyPS8DhoGfEnaCgJyKf6GU4p41502gH8mQRB7azzL7 +5jW0aFatCA6G6T1oogHZpHf0ice80C2JkFbWHSE9JxqARbTc06wCDBiSBFTGZQDO +JaBIbn6FL3zSkKcpwgJEqDRavVuoDUlJPDqtTzjf/fMQGGR2LUFkceJpsQqf1jrF +1yTtEZ8gjR2g2Vj6IszUAgbc87xR0AgyGDVckiUdhlX2Y6KCqo2cl9LfSVpqtw== +-----END CERTIFICATE----- diff --git a/testdata/leaf.crt b/testdata/leaf.crt new file mode 100644 index 0000000..1d05047 --- /dev/null +++ b/testdata/leaf.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQjCCAiqgAwIBAgIUHT3JtqsYKs7NHv1LNyS9RYC7vsAwDQYJKoZIhvcNAQEL +BQAwHzEdMBsGA1UEAwwUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwHhcNMjUwMjI4MTIw +MTMwWhcNMjYwNzEzMTIwMTMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHwI/xSLgCuQrx+WsvupA8w4eMdSef +WGw523OJYPJkDYJGgSGsjVb9htba6vXYbGNohuluEAZIyT7GvmPezTokeVMkuSYT +lSV8xplFEtDlQhTzaI/cofbi7qtT91/5zS/w0JSaNosThGtZg/M4ZOiMj04m0NGK +Zz56l9Lpe/yM7fPda++D9xYEGSSdwK9CqqwF+cXN09d6IK1VINIIjT3Sdb9Sssok +GWmD7UUPLvwZ5379+HRs1K8AFXqvbkeWVYtrJwJMxJGVnNSeiqKGSmMEpP7tVNHl +s4V7oyQXd8KX+HpziiayjGy9giVteJJi/bAmUp+0+hTHBes5fOWI5JyDAgMBAAGj +gYAwfjAfBgNVHSMEGDAWgBSmAyntm95+KoyL3ffLBXZKSpk1VTAJBgNVHRMEAjAA +MAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAPBgNVHREECDAGhwR/ +AAABMB0GA1UdDgQWBBTVdJE2lkGsNIU3LlEf3rN5fyaRkjANBgkqhkiG9w0BAQsF +AAOCAQEAo1klH9WMsPWTN9qN3tdud07eatulEKo/0okaph6MJ59ozseOzxrfpwL0 +67Nr8yl+VwZqrRTBurp0n6G+n0j8UHfWjSrAqN4yUHl+heT0HpnLR2FE9YgZEmxR +bPfVbPBef/eJeE7/U6imfBYzzMajua+hg05sVHUNNdPaFOP+Xj47x8uQmf9w5/kf +MrylRUSgH5RRge4+2T5hmNM9tHfF6OfDHitrXnl+X6h/x/tkBvDcUXtKa5xuEcSg +WpmJKl3pKfXvdmCIrj9Vca+UD2Bntkk2jgDTLEPJAxMgrsQRhnUJclaunnd1NQbc +FmjFW7iaNvDVKt+vYqH8ff8U9iCB2g== +-----END CERTIFICATE----- diff --git a/testdata/leaf.key b/testdata/leaf.key new file mode 100644 index 0000000..0fee211 --- /dev/null +++ b/testdata/leaf.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDHwI/xSLgCuQrx ++WsvupA8w4eMdSefWGw523OJYPJkDYJGgSGsjVb9htba6vXYbGNohuluEAZIyT7G +vmPezTokeVMkuSYTlSV8xplFEtDlQhTzaI/cofbi7qtT91/5zS/w0JSaNosThGtZ +g/M4ZOiMj04m0NGKZz56l9Lpe/yM7fPda++D9xYEGSSdwK9CqqwF+cXN09d6IK1V +INIIjT3Sdb9SssokGWmD7UUPLvwZ5379+HRs1K8AFXqvbkeWVYtrJwJMxJGVnNSe +iqKGSmMEpP7tVNHls4V7oyQXd8KX+HpziiayjGy9giVteJJi/bAmUp+0+hTHBes5 +fOWI5JyDAgMBAAECggEADanzbrrds3n68LByD5LAeRea9xWwfyrqRE7pqVUTX5q4 +9Z+xsP7+G1uU6Oa3qHVJm4XXA+tesq3peGjfpgb92i7ebB2qKB7EsLNZGqt91KDf +lALsDFib7cwLtjOuwgSyKdPqxl3Cx7QAL+Bhy9LDQZIv82HHY6NKV9J43/XWQcGK +KNZyS1o0vBWvt135YeE3qfQA9Ww8GI3jWyk49QDOVtVNZ1HRQpXPkt5exAyU8JpW +Y3Y5VqyEcKPBRlw/scEc5CRuzIP3P06Y+NEuuvnlnDt/BR/wyyuPiZoIqGXhXUKe +oDzEmtVrvB5RmhRc1PoS1l1GBfBfVqwkLUmeSitn8QKBgQD1pC6Ukiqtm7vyhirU +ynE+Dik/gHBBW6fQalD7yZwStid1+HfIvoU+RB0wyPUVwbu7eOoMnjG2ChLYX7cV +UhAcu5ZWFhlc8OS5bGGMI99d4ueBqTYONqnCV3DhiWZhs8OesiF3hohE6jV3G5xC +ra8DzImMpujyMIWXvOwnPLRWCQKBgQDQLPwTvx472XbY1aWviQkzxMgxP8l9D+lO +nBaybarLxDa+89RMWidgJX2kGM7i6FgicGgSNpNDIYNLrVZYAz11PP6o2Oqa9ZaR +5IvnfhP1iOwSgIoC8weNSE+Y0Lw/w0IOW71+XsfgswhJG45eXV8hRqYpUEkjjl1x +nQM0hCshKwKBgCQDFfEiHK+nDT7Y/J6Fr2Rxnwp4QfzS+x9K9uRzAjacDdz1uFnt +1Ir0YXMtgwDVjjhF2cpPxunxQCIIpkax6TrNJZUpWD6P8nhcs1BgUfbptRcFP6+F +xA2B1EK8ag4Y1K0HYHCtgHzZ+Uyk95uu6uGbsu6z6aLYCj3crKJz+9xBAoGACOdT +pLiQ33hul9mTa42N4jPxaAHVaU7r6JvOcLU2D98FhGdDVjyo4HjaBdG1z4imdFqg +aN8Cr2VYiz0Pq1YAI+qG7cvRRO1qEjVXMoB29BJ2Hlh3Dqc8VHOaS+vpkUSVp62O +zj/ZhqfBm/bcwPZ3YiH2a1/usOGe54QSpgVdHt8CgYBr6xpKQSrQvgtiuv9kCzI+ +WQYP2Xxj+zsQb29hagXY/JllKOl5aDGz2qMV2RgOWZabB/xxkAeTF4A26wWrmcq0 +wsv96jEl1MtI3lB3bi/8Y/tctkNsp0drvDZdfnGMpzxDafECKdxdFvhO63p7yBOA +LG1OvFTywkxBuOUKsNsErQ== +-----END PRIVATE KEY----- diff --git a/testdata/rootCA.crt b/testdata/rootCA.crt new file mode 100644 index 0000000..a6b6bda --- /dev/null +++ b/testdata/rootCA.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDHzCCAgegAwIBAgIUUmc9nWf4fhGFNd0oCNE0CzOXMaEwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MDIyODEyMDEyOVoXDTI3 +MTIxOTEyMDEyOVowFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw5ZFoDGTL0YyrwEA7qTu32cur0sQXDV86Xwl +G0ilk2DXXH4F70ruTTW3NG0Rniw/rt2jzJADo1Tlosq9eJKQGQKAr21N5kjhlU3J +8nFBK+1WJyG27EvyeqZOCucXOJaAm0HSbhlT0MYpZ4kzuxmOUPmTsJmt2BtK+uRU +3LlXtzyZnJo53azQuLZz26tBGd9LXsBUMi+KJ0eX1HPluIT3o+nslnJZaqGySLKm +cJnLf9hio+rAwFBb8sgDdzeI7jqZ2bmAGPJBYpIT/dIxuZUkgTfX+OMp2g3RnQea +M0w0UjhbbQeAJONH9HGREDdp7tYtuyuBbE4miNTyjSsouqk6AwIDAQABo2MwYTAd +BgNVHQ4EFgQUfd/q0BY4fkVBV3X+HWzXH0toW08wHwYDVR0jBBgwFoAUfd/q0BY4 +fkVBV3X+HWzXH0toW08wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw +DQYJKoZIhvcNAQELBQADggEBAGLUya6xwaCwUPWHNOjlvGBGzGwAmSstJzh5o77O +XTTvyCwb0p80AnS9XoX3An5e4ePzw69mEw6RzfVLIex7fCRDekqPFuSWjVagKGJc +G7nvCqdHoCh2z1Jkb9gFpYPd6p45dtLWBw9e9/t9cFHtDR6stC16/Hy8cLzEIr0c +EWxCNdJdZW+soJivaZQeVWtlMXxVpGIs8i33CAFYufZCTKMgyYRegZuMQ676OcDE +9VSi2vJnnhdn7OBip82xX3NDQrwVt60fvFMr25cPOlzhXRY4mQLslGOleqT3sSPV +DVJnOBBmdjgFQQ8BO7rFUNGGOaUcEZp0HLRwxPZyc6OBCIg= +-----END CERTIFICATE----- diff --git a/testdata/server.crt b/testdata/server.crt new file mode 100644 index 0000000..6093005 --- /dev/null +++ b/testdata/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOjCCAiKgAwIBAgIUQr7R8yN5+2and6ucUOPF6oIbD48wDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MDIyODEyMDEzMFoXDTI2 +MDcxMzEyMDEzMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA4oL2hAPQlDVaNJru5fIstkpoVSuam0vpswC7ciRc +XQRjF3q8kjtIA7+jdySsKJqOLGnybDX3awvRyKMEjq11IfnZLjZc+FzTlA+x4z0h +MHb0GiBFXKNzrExGI9F0KEPtFxcMIqZ119LY2ReexxWkZBQYlgTepaevp71za4c2 +n4Zy1+0iS5+uklZ4ANKMTBGlN76Qgt530VnpNiIeUbiUzY58Vx4q7kFcUv/oSz8p +rbXr+/GGpAjrOc6/JsezRE8YK2po60dvV80TJ2Jt6pduvF7OSQnq/v4mJl1xuXKl +Byo9HLbeu3BuVRWQs2/EwEzx5kX3Ugysl9Bm44K2yKe9/QIDAQABo4GAMH4wHwYD +VR0jBBgwFoAUfd/q0BY4fkVBV3X+HWzXH0toW08wCQYDVR0TBAIwADALBgNVHQ8E +BAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0RBAgwBocEfwAAATAdBgNV +HQ4EFgQUe0rTTfWjho3hgeLTnajTCpddo2MwDQYJKoZIhvcNAQELBQADggEBAIR2 +5zkA7rPnddxCunsz8Jjq3wyhR/KiAFz+RGeFeiXDkF2fWr7QIQ9KbFbv8tpfXR7P +B75bY0sXwutHMB2sZDi92cH5sthNBfp19fI35cxcU4oTPxp4UZJKEiA3Qx8y73CX +NJu1009nPdOJNlIboDGAFdZ5SH6RCh+YcQZ68kjHPWBIpXxLbs9FN3QmpbAvtLh1 +PoPaSy7IjKmxm1u+Lf6tyIn2IiB3MiynaB3OKvbkLCseM/5SZKMk6WKSDWopOCJr +xciPOc+yeLz5I2Omn0uViOIIciqjlgxncWAyNtDgvJcecwqB2cPiIhk6GY0QZ1uM +e7KoqGzWXvWLqJ13a9U= +-----END CERTIFICATE----- diff --git a/testdata/server.key b/testdata/server.key new file mode 100644 index 0000000..064c6c1 --- /dev/null +++ b/testdata/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDigvaEA9CUNVo0 +mu7l8iy2SmhVK5qbS+mzALtyJFxdBGMXerySO0gDv6N3JKwomo4safJsNfdrC9HI +owSOrXUh+dkuNlz4XNOUD7HjPSEwdvQaIEVco3OsTEYj0XQoQ+0XFwwipnXX0tjZ +F57HFaRkFBiWBN6lp6+nvXNrhzafhnLX7SJLn66SVngA0oxMEaU3vpCC3nfRWek2 +Ih5RuJTNjnxXHiruQVxS/+hLPymttev78YakCOs5zr8mx7NETxgramjrR29XzRMn +Ym3ql268Xs5JCer+/iYmXXG5cqUHKj0ctt67cG5VFZCzb8TATPHmRfdSDKyX0Gbj +grbIp739AgMBAAECggEADgUIbbAFbJbyHV1q5Jqc/9oSeRW40lyG0Mh+fEMZ4Gam +x3ZA+QAS+1W/hV6ktTf+YsCv+4NKQWWQN3iM41PYcyDmu1XWt/Hu5TQk0NQgxhd8 +EP3nAnkvbf5OkmWiveHuaRvJFCqfZ/Cp8U3lSvHg+edwhMs1CKXHWSeAXwBrIMEb +ajpxuD3B/NT/CGmKnj3cgAuIbvNHVIcwu8ACbpczDL++vi7KrWmOJn1QzSlUlNFi +fsgnF0heO5Uff4vkjXU84INQxOP3tbvXcDNiwDewZy75h2d3Pv+ku8GoZYWFUXSJ +yKtafJMJUD0kJMuKhkzrwYcQGY6ioSYisPK+JoungQKBgQD8fWmuHwCXbM4Ckyns +Wg4f+kG8d+wypgIs6ENmgr9UnNB0N6n7nO7v/4l/l7IN9CQQmdtSvek2ytk5rGBM +XUAWxZaokE3MecxR0EUJx42k/k3dN4XgU/YNk4D6/wpEsyUATE4nIFDVjxE+Jc07 +CZ2CUWKyxTPGz2kfHnEQ0vFiYQKBgQDlqRiGlJ6c99zTas2wrvr+50aQhn6BryDK +kjGM6woPnnwMq+Jy6vum3o0cU+iNNeFAijShXo2XR3iZJcoJ2sPhy3dRWdBNdFyy +hwxgD0cXzEjQL0M03DPDykTnM7ZvE6KUZjnxJZkytJHLKapoGzxBH9656zx5qnuH +MPYwTWg5HQKBgDKBD4OBtgeT/v0q3KbnOI4S69U8E6Xp6ON8rgayPn05RMUKYVjw +AidFcQZxnG8IF7KuY92AGUcZeiv8G+MKgAhOC526B6XP8xumUjjrjpyjNYX7Vi8R +/FSo3ZLXMwGc59jQao2O/DxLesJ4oz2c5cGsb9acdYfd8wQDfdBEsX3hAoGAc4Pu +NiMi9MknZZ/e/fPFg9lIgQFlOE2iLMID8mF2mgyZULZUHIFdOr3ONGVwHzbuqcva +VSB+D41/d2iuiu5igHwa8+w8/fh9d7691sNYevvh0/Ux1LC9yMlAhxpXtN8nc4VH +t6e1uu9gNdQrRloMoKUrHlDYBkpd/838xqbouXECgYEArwn+eXKD5zgNN4jEbNBp +ygIp+Oh2abt+CNQjfLUa+qon5ziH53mHixJ2hpaOa6Rxxu9R2ZgNLtbodm+ccD8z +ZNA7Z0rApAwfuhD8zIzkZ4HuARN8eopYmTubpzDkAcfRWhw1EBDQc0V6trl+EJsK +xfbmGepRVWXw2dLmxhA9/zM= +-----END PRIVATE KEY----- diff --git a/testserver/server.go b/testserver/server.go new file mode 100644 index 0000000..abe9c19 --- /dev/null +++ b/testserver/server.go @@ -0,0 +1,159 @@ +package testserver + +import ( + "net/http" + "net/http/httptest" + "strings" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/onsi/ginkgo/v2" + "github.com/stretchr/testify/require" +) + +// New returns a new httptest.Server that handles the +// authentication requests neeeded to create a new client. Any requests not +// made to the /actions/runners/registration-token or +// /actions/runner-registration endpoints will be handled by the provided +// handler. The returned server is started and will be automatically closed +// when the test ends. +// +// TODO: this uses ginkgo interface _only_ to support our current controller tests +func New(t ginkgo.GinkgoTInterface, handler http.Handler, options ...actionsServerOption) *actionsServer { + s := NewUnstarted(t, handler, options...) + s.Start() + return s +} + +// TODO: this uses ginkgo interface _only_ to support our current controller tests +func NewUnstarted(t ginkgo.GinkgoTInterface, handler http.Handler, options ...actionsServerOption) *actionsServer { + s := httptest.NewUnstartedServer(handler) + server := &actionsServer{ + Server: s, + } + t.Cleanup(func() { + server.Close() + }) + + server.setDefaults(t) + + for _, option := range options { + option(server) + } + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // handle getRunnerRegistrationToken + if strings.HasSuffix(r.URL.Path, "/runners/registration-token") { + server.runnerRegistrationTokenHandler(w, r) + return + } + + // handle getActionsServiceAdminConnection + if strings.HasSuffix(r.URL.Path, "/actions/runner-registration") { + server.actionRegistrationTokenHandler(w, r) + return + } + + handler.ServeHTTP(w, r) + }) + + server.Config.Handler = h + + return server +} + +type actionsServerOption func(*actionsServer) + +func WithActionsToken(token string) actionsServerOption { + return func(s *actionsServer) { + s.token = token + } +} + +func WithRunnerRegistrationTokenHandler(h http.HandlerFunc) actionsServerOption { + return func(s *actionsServer) { + s.runnerRegistrationTokenHandler = h + } +} + +func WithActionsRegistrationTokenHandler(h http.HandlerFunc) actionsServerOption { + return func(s *actionsServer) { + s.actionRegistrationTokenHandler = h + } +} + +type actionsServer struct { + *httptest.Server + + token string + runnerRegistrationTokenHandler http.HandlerFunc + actionRegistrationTokenHandler http.HandlerFunc +} + +func (s *actionsServer) setDefaults(t ginkgo.GinkgoTInterface) { + if s.runnerRegistrationTokenHandler == nil { + s.runnerRegistrationTokenHandler = func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"token":"token"}`)) + } + } + + if s.actionRegistrationTokenHandler == nil { + s.actionRegistrationTokenHandler = func(w http.ResponseWriter, r *http.Request) { + if s.token == "" { + s.token = DefaultActionsToken(t) + } + + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"url":"` + s.URL + `/tenant/123/","token":"` + s.token + `"}`)) + } + } +} + +func (s *actionsServer) ConfigURLForOrg(org string) string { + return s.URL + "/" + org +} + +func DefaultActionsToken(t ginkgo.GinkgoTInterface) string { + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), + Issuer: "123", + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(samplePrivateKey)) + require.NoError(t, err) + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + return tokenString +} + +const samplePrivateKey = `-----BEGIN PRIVATE KEY----- +MIIEugIBADANBgkqhkiG9w0BAQEFAASCBKQwggSgAgEAAoIBAQC7tgquvNIp+Ik3 +rRVZ9r0zJLsSzTHqr2dA6EUUmpRiQ25MzjMqKqu0OBwvh/pZyfjSIkKrhIridNK4 +DWnPfPWHE2K3Muh0X2sClxtqiiFmXsvbiTzhUm5a+zCcv0pJCWYnKi0HmyXpAXjJ +iN8mWliZN896verVYXWrod7EaAnuST4TiJeqZYW4bBBG81fPNc/UP4j6CKAW8nx9 +HtcX6ApvlHeCLZUTW/qhGLO0nLKoEOr3tXCPW5VjKzlm134Dl+8PN6f1wv6wMAoA +lo7Ha5+c74jhPL6gHXg7cRaHQmuJCJrtl8qbLkFAulfkBixBw/6i11xoM/MOC64l +TWmXqrxTAgMBAAECgf9zYlxfL+rdHRXCoOm7pUeSPL0dWaPFP12d/Z9LSlDAt/h6 +Pd+eqYEwhf795SAbJuzNp51Ls6LUGnzmLOdojKwfqJ51ahT1qbcBcMZNOcvtGqZ9 +xwLG993oyR49C361Lf2r8mKrdrR5/fW0B1+1s6A+eRFivqFOtsOc4V4iMeHYsCVJ +hM7yMu0UfpolDJA/CzopsoGq3UuQlibUEUxKULza06aDjg/gBH3PnP+fQ1m0ovDY +h0pX6SCq5fXVJFS+Pbpu7j2ePNm3mr0qQhrUONZq0qhGN/piCbBZe1CqWApyO7nA +B95VChhL1eYs1BKvQePh12ap83woIUcW2mJF2F0CgYEA+aERTuKWEm+zVNKS9t3V +qNhecCOpayKM9OlALIK/9W6KBS+pDsjQQteQAUAItjvLiDjd5KsrtSgjbSgr66IP +b615Pakywe5sdnVGzSv+07KMzuFob9Hj6Xv9als9Y2geVhUZB2Frqve/UCjmC56i +zuQTSele5QKCSSTFBV3423cCgYEAwIBv9ChsI+mse6vPaqSPpZ2n237anThMcP33 +aS0luYXqMWXZ0TQ/uSmCElY4G3xqNo8szzfy6u0HpldeUsEUsIcBNUV5kIIb8wKu +Zmgcc8gBIjJkyUJI4wuz9G/fegEUj3u6Cttmmj4iWLzCRscRJdfGpqwRIhOGyXb9 +2Rur5QUCgYAGWIPaH4R1H4XNiDTYNbdyvV1ZOG7cHFq89xj8iK5cjNzRWO7RQ2WX +7WbpwTj3ePmpktiBMaDA0C5mXfkP2mTOD/jfCmgR6f+z2zNbj9zAgO93at9+yDUl +AFPm2j7rQgBTa+HhACb+h6HDZebDMNsuqzmaTWZuJ+wr89VWV5c17QKBgH3jwNNQ +mCAIUidynaulQNfTOZIe7IMC7WK7g9CBmPkx7Y0uiXr6C25hCdJKFllLTP6vNWOy +uCcQqf8LhgDiilBDifO3op9xpyuOJlWMYocJVkxx3l2L/rSU07PYcbKNAFAxXuJ4 +xym51qZnkznMN5ei/CPFxVKeqHgaXDpekVStAoGAV3pSWAKDXY/42XEHixrCTqLW +kBxfaf3g7iFnl3u8+7Z/7Cb4ZqFcw0bRJseKuR9mFvBhcZxSErbMDEYrevefU9aM +APeCxEyw6hJXgbWKoG7Fw2g2HP3ytCJ4YzH0zNitHjk/1h4BG7z8cEQILCSv5mN2 +etFcaQuTHEZyRhhJ4BU= +-----END PRIVATE KEY-----` diff --git a/types.go b/types.go new file mode 100644 index 0000000..1852b84 --- /dev/null +++ b/types.go @@ -0,0 +1,158 @@ +package scaleset + +import ( + "time" + + "github.com/google/uuid" +) + +type AcquirableJobList struct { + Count int `json:"count"` + Jobs []AcquirableJob `json:"value"` +} + +type AcquirableJob struct { + AcquireJobUrl string `json:"acquireJobUrl"` + MessageType string `json:"messageType"` + RunnerRequestId int64 `json:"runnerRequestId"` + RepositoryName string `json:"repositoryName"` + OwnerName string `json:"ownerName"` + JobWorkflowRef string `json:"jobWorkflowRef"` + EventName string `json:"eventName"` + RequestLabels []string `json:"requestLabels"` +} + +type Int64List struct { + Count int `json:"count"` + Value []int64 `json:"value"` +} + +type JobAvailable struct { + AcquireJobUrl string `json:"acquireJobUrl"` + JobMessageBase +} + +type JobAssigned struct { + JobMessageBase +} + +type JobStarted struct { + RunnerID int `json:"runnerId"` + RunnerName string `json:"runnerName"` + JobMessageBase +} + +type JobCompleted struct { + Result string `json:"result"` + RunnerId int `json:"runnerId"` + RunnerName string `json:"runnerName"` + JobMessageBase +} + +type JobMessageType struct { + MessageType string `json:"messageType"` +} + +type JobMessageBase struct { + JobMessageType + RunnerRequestID int64 `json:"runnerRequestId"` + RepositoryName string `json:"repositoryName"` + OwnerName string `json:"ownerName"` + JobID string `json:"jobId"` + JobWorkflowRef string `json:"jobWorkflowRef"` + JobDisplayName string `json:"jobDisplayName"` + WorkflowRunID int64 `json:"workflowRunId"` + EventName string `json:"eventName"` + RequestLabels []string `json:"requestLabels"` + QueueTime time.Time `json:"queueTime"` + ScaleSetAssignTime time.Time `json:"scaleSetAssignTime"` + RunnerAssignTime time.Time `json:"runnerAssignTime"` + FinishTime time.Time `json:"finishTime"` +} + +type Label struct { + Type string `json:"type"` + Name string `json:"name"` +} + +type RunnerGroup struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + IsDefault bool `json:"isDefaultGroup"` +} + +type RunnerGroupList struct { + Count int `json:"count"` + RunnerGroups []RunnerGroup `json:"value"` +} + +type RunnerScaleSet struct { + Id int `json:"id,omitempty"` + Name string `json:"name,omitempty"` + RunnerGroupId int `json:"runnerGroupId,omitempty"` + RunnerGroupName string `json:"runnerGroupName,omitempty"` + Labels []Label `json:"labels,omitempty"` + RunnerSetting RunnerSetting `json:"RunnerSetting,omitempty"` + CreatedOn time.Time `json:"createdOn,omitempty"` + RunnerJitConfigUrl string `json:"runnerJitConfigUrl,omitempty"` + Statistics *RunnerScaleSetStatistic `json:"statistics,omitempty"` +} + +type RunnerScaleSetJitRunnerSetting struct { + Name string `json:"name"` + WorkFolder string `json:"workFolder"` +} + +type RunnerScaleSetMessage struct { + MessageId int64 `json:"messageId"` + MessageType string `json:"messageType"` + Body string `json:"body"` + Statistics *RunnerScaleSetStatistic `json:"statistics"` +} + +type runnerScaleSetsResponse struct { + Count int `json:"count"` + RunnerScaleSets []RunnerScaleSet `json:"value"` +} + +type RunnerScaleSetSession struct { + SessionId *uuid.UUID `json:"sessionId,omitempty"` + OwnerName string `json:"ownerName,omitempty"` + RunnerScaleSet *RunnerScaleSet `json:"runnerScaleSet,omitempty"` + MessageQueueUrl string `json:"messageQueueUrl,omitempty"` + MessageQueueAccessToken string `json:"messageQueueAccessToken,omitempty"` + Statistics *RunnerScaleSetStatistic `json:"statistics,omitempty"` +} + +type RunnerScaleSetStatistic struct { + TotalAvailableJobs int `json:"totalAvailableJobs"` + TotalAcquiredJobs int `json:"totalAcquiredJobs"` + TotalAssignedJobs int `json:"totalAssignedJobs"` + TotalRunningJobs int `json:"totalRunningJobs"` + TotalRegisteredRunners int `json:"totalRegisteredRunners"` + TotalBusyRunners int `json:"totalBusyRunners"` + TotalIdleRunners int `json:"totalIdleRunners"` +} + +type RunnerSetting struct { + Ephemeral bool `json:"ephemeral,omitempty"` + IsElastic bool `json:"isElastic,omitempty"` + DisableUpdate bool `json:"disableUpdate,omitempty"` +} + +type RunnerReferenceList struct { + Count int `json:"count"` + RunnerReferences []RunnerReference `json:"value"` +} + +type RunnerReference struct { + Id int `json:"id"` + Name string `json:"name"` + RunnerScaleSetId int `json:"runnerScaleSetId"` +} + +type RunnerScaleSetJitRunnerConfig struct { + Runner *RunnerReference `json:"runner"` + EncodedJITConfig string `json:"encodedJITConfig"` +} diff --git a/user_agent_test.go b/user_agent_test.go new file mode 100644 index 0000000..1f0555f --- /dev/null +++ b/user_agent_test.go @@ -0,0 +1,24 @@ +package scaleset_test + +import ( + "testing" + + "github.com/actions/scaleset" + "github.com/stretchr/testify/assert" +) + +func TestUserAgentInfoString(t *testing.T) { + userAgentInfo := scaleset.UserAgentInfo{ + Version: "0.1.0", + CommitSHA: "1234567890abcdef", + ScaleSetID: 10, + HasProxy: true, + Subsystem: "test", + } + + userAgent := userAgentInfo.String() + expectedProduct := "actions-runner-controller/0.1.0 (1234567890abcdef; test)" + assert.Contains(t, userAgent, expectedProduct) + expectedScaleSet := "ScaleSetID/10 (Proxy/enabled)" + assert.Contains(t, userAgent, expectedScaleSet) +}