Initial commit for open source release 🚀

Co-authored-by: Francesco Renzi <rentziass@github.com>
Co-authored-by: Nikola Jokic <jokicnikola07@gmail.com>
This commit is contained in:
Francesco Renzi
2026-02-03 16:41:15 +01:00
commit e4a017ce06
44 changed files with 7340 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm
USER vscode
+33
View File
@@ -0,0 +1,33 @@
{
"build": {
"dockerfile": "Dockerfile"
},
"features": {
"ghcr.io/devcontainers/features/sshd:1": "latest",
"ghcr.io/devcontainers/features/github-cli:1": {},
// Node is here only to support Copilot extension
"ghcr.io/devcontainers/features/node:1": {}
},
"hostRequirements": {
"cpus": 8,
"memory": "16gb"
},
"customizations": {
"vscode": {
// Add the IDs of extensions you want installed when the container is created.
"extensions": [
"golang.Go",
"github.copilot"
],
// Set *default* container specific settings.json values on container create.
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go",
"gopls": {
"formatting.gofumpt": true
}
}
}
}
}
+1
View File
@@ -0,0 +1 @@
* @actions/actions-runtime @nikola-jokic
+55
View File
@@ -0,0 +1,55 @@
name: E2E
on:
push:
branches:
- main
pull_request:
branches:
- main
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }}
cancel-in-progress: true
env:
E2E_WORKFLOW_TARGET_ORG: "scaleset-canary"
E2E_WORKFLOW_TARGET_REPO: "e2e"
jobs:
basic-e2e:
name: Basic E2E
runs-on: ubuntu-latest
timeout-minutes: 20
env:
E2E_WORKFLOW_TARGET_FILE: "basic.yaml"
steps:
- uses: actions/checkout@v6
with:
persist-credentials: true
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
- name: Get configure token
id: config-token
uses: peter-murray/workflow-application-token-action@d17e3a9a36850ea89f35db16c1067dd2b68ee343
with:
application_id: ${{ secrets.E2E_TESTS_ACCESS_CLIENT_ID }}
application_private_key: ${{ secrets.E2E_TESTS_ACCESS_PK }}
organization: ${{ env.E2E_WORKFLOW_TARGET_ORG }}
- name: Run simple test
run: |
git config --global url."https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/actions/scaleset".insteadOf "https://github.com/actions/scaleset"
E2E_SCALESET_NAME="basic-$(date +'%M%S')$(((RANDOM + 100) % 100 + 1))" go test
working-directory: examples/dockerscaleset
env:
GOPRIVATE: github.com/actions/scaleset
GONOSUMDB: github.com/actions/scaleset
E2E_SCALESET_GITHUB_TOKEN: "${{steps.config-token.outputs.token}}"
E2E_WORKFLOW_GITHUB_TOKEN: "${{steps.config-token.outputs.token}}"
E2E_SCALESET_URL: "https://github.com/scaleset-canary/e2e"
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
E2E: "true"
+68
View File
@@ -0,0 +1,68 @@
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
mocks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: "go.mod"
cache: false
- name: "Run mockery"
run: go tool github.com/vektra/mockery/v3
- 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: true
- name: Run tests
run: go test ./...
+11
View File
@@ -0,0 +1,11 @@
version: "2"
run:
timeout: 5m
linters:
settings:
errcheck:
exclude-functions:
- (net/http.ResponseWriter).Write
exclusions:
presets:
- std-error-handling
+14
View File
@@ -0,0 +1,14 @@
all: false
dir: "{{.InterfaceDir}}"
filename: mocks_test.go
force-file-write: true
formatter: goimports
log-level: info
structname: "{{.Mock}}{{.InterfaceName}}"
pkgname: "{{.SrcPackageName}}"
recursive: true
template: testify
packages:
github.com/actions/scaleset/listener:
config:
all: true
+74
View File
@@ -0,0 +1,74 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, gender identity and expression, level of experience,
nationality, personal appearance, race, religion, or sexual identity and
orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at <opensource@github.com>. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
+41
View File
@@ -0,0 +1,41 @@
## Contributing
[fork]: https://github.com/actions/scaleset/fork
[pr]: https://github.com/actions/scaleset/compare
[style]: https://github.com/actions/scaleset/blob/main/.golangci.yaml
Hi there! We're thrilled that you'd like to contribute to this project. Your help is essential for keeping it great.
Contributions to this project are [released](https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license) to the public under the [project's open source license](LICENSE).
Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
## Prerequisites for running and testing code
These are one time installations required to be able to test your changes locally as part of the pull request (PR) submission process.
1. install Go [through download](https://go.dev/doc/install) | [through Homebrew](https://formulae.brew.sh/formula/go)
1. [install golangci-lint](https://golangci-lint.run/welcome/install/#local-installation)
## Submitting a pull request
1. [Fork][fork] and clone the repository
1. Make sure the tests pass on your machine: `go test -v ./...`
1. Make sure linter passes on your machine: `script/lint`
1. Create a new branch: `git checkout -b my-branch-name`
1. Make your change, add tests, and make sure the tests and linter still pass
1. Push to your fork and [submit a pull request][pr]
1. Pat yourself on the back and wait for your pull request to be reviewed and merged.
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
- Follow the [style guide][style].
- Write tests.
- Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests.
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
## Resources
- [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/)
- [Using Pull Requests](https://help.github.com/articles/about-pull-requests/)
- [GitHub Help](https://help.github.com)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright GitHub, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+185
View File
@@ -0,0 +1,185 @@
# GitHub Actions Runner Scale Set Client (Private Preview)
> Status: **Private Preview** While the API is stable, interfaces and examples in this repository may change.
This repository provides a standalone Go client for the GitHub Actions **Runner Scale Set** APIs. It is extracted from the `actions-runner-controller` project so that platform teams, integrators, and infrastructure providers can build **their own custom autoscaling solutions** for GitHub Actions runners.
You do *not* need to adopt the full controller (and Kubernetes) to take advantage of scale sets. This package contains all the primitives you need: create/update/delete scale sets, generate justintime (JIT) runner configs, and manage message sessions.
---
## What is a Scale Set?
A runner scale set is a group of self-hosted runners that autoscales based on workflow demand. Here's how it works:
1. **Registration**: You create a scale set with a name, which also serves as the label workflows use to target it (e.g., `runs-on: my-scale-set`). Like regular self-hosted runners, scale sets can be registered at the repository, organization, or enterprise level.
2. **Polling**: Your scale set client continuously polls the API, reporting its maximum capacity (how many runners it can produce).
3. **Job matching**: GitHub matches jobs to your scale set based on the label and runner group policies, just like regular self-hosted runners.
4. **Scaling signal**: The API responds with how many runners your scale set needs online (`statistics.TotalAssignedJobs`).
5. **Runner provisioning**: Your client creates or maintains enough runners to meet demand. Runners can be created just-in-time as jobs arrive, or pre-provisioned ahead of demand to reduce latency.
6. **Job assignment**: GitHub assigns a pending job to any idle runner in the scale set.
Runners in a scale set are ephemeral by default: each runner executes one job and is then removed. This ensures a clean environment for every job.
---
## High-Level Flow
1. Create a `Client` with either a GitHub App credential (recommended) or a PAT.
2. Create a Runner Scale Set with a name.
3. Start a message session and poll for scaling events. The `listener` package handles this for you.
4. When the API indicates runners are needed:
- Call `GenerateJitRunnerConfig` to get a JIT config for a new runner.
- Start your runner (process, container, VM, etc.) with the JIT config.
5. Idle runners are assigned jobs automatically by GitHub.
You can also pre-provision runners before jobs arrive to reduce startup latency. See [`examples/dockerscaleset`](./examples/dockerscaleset) for a complete example that supports both `minRunners` (pre-provisioned) and just-in-time scaling.
---
## Autoscaling
Use `statistics.TotalAssignedJobs` from each message response to determine how many runners your scale set needs online. This value represents the total number of jobs assigned to your scale set, including both jobs waiting for a runner and jobs already running (`TotalAssignedJobs >= TotalRunningJobs`).
Do not count individual job messages (`JobAssigned`, `JobStarted`, `JobCompleted`) in the response body to determine scaling:
- Responses contain at most 50 messages. Large backlogs will be truncated.
- The `statistics` field is always current and reflects the true state of your scale set.
When polling for messages, include your scale set's maximum capacity via the `maxCapacity` parameter (sent as the `X-ScaleSetMaxCapacity` header). This allows the backend to assign jobs accurately and avoid creating backlogs your scale set cannot fulfill.
Here's a simplified polling loop:
```go
var lastMessageID int
for {
msg, err := client.GetMessage(ctx, lastMessageID, maxCapacity)
if err != nil {
return err
}
if msg == nil {
// No messages available (202 response), poll again
continue
}
lastMessageID = msg.MessageID
// Scale based on statistics, not message counts
desiredRunners := msg.Statistics.TotalAssignedJobs
scaleToDesired(desiredRunners)
// Acknowledge the message
if err := client.DeleteMessage(ctx, msg.MessageID); err != nil {
return err
}
}
```
The `listener` package provides a ready-to-use implementation of this pattern, handling session management, polling, and acknowledgment. See [`listener/listener.go`](./listener/listener.go).
### Job lifecycle messages
Individual job messages (`JobStarted`, `JobCompleted`, etc.) are useful for purposes beyond scaling. For example, [actions-runner-controller](https://github.com/actions/actions-runner-controller) uses `JobStarted` to mark runner pods as busy, preventing premature cleanup during scale-down. These messages can also be used for metrics or logging.
See [`types.go`](./types.go) for payload definitions.
---
## How the Message API Works
### Long Polling
`GetMessage` uses long polling:
1. If messages are available, they are returned immediately.
2. Otherwise, the request blocks for up to ~50 seconds.
3. If no messages arrive, a 202 response is returned (`nil, nil` in the Go client).
Poll again immediately after handling each response.
### Message Acknowledgment
Call `DeleteMessage` after processing a message. This acts as an acknowledgment:
- Unacknowledged messages are redelivered on the next poll.
- This prevents message loss if your client crashes mid-processing.
### Message ID Tracking
Pass the ID of the last processed message to `GetMessage`. Omitting this (or passing 0) returns the first available message, potentially causing reprocessing.
### Job Reassignment
Jobs may appear multiple times as `JobAssigned` followed by `JobCompleted` (with `result: "canceled"`). This occurs when a job is assigned to your scale set but not acquired by a runner in time—GitHub cancels the assignment and requeues the job. This can happen up to 3 times with incremental delays.
Each attempt generates new messages, but they represent the same workflow job. This is why `statistics.TotalAssignedJobs` is the correct scaling metric: it reflects the current state, not the message history.
---
## Getting Started
```bash
go get github.com/actions/scaleset@latest
```
Import:
```go
import "github.com/actions/scaleset"
```
### Using Without Go Experience
If you are not a Go developer, you can still:
- Treat this repo as reference documentation to design an API integration in another language.
- Vendor the code and compile a minimal binary that exposes a simpler CLI.
- Use the example CLI (`examples/dockerscaleset`) as inspiration—its flags show required inputs.
- Copilot can also help you translate this Go code into your language of choice.
---
## Authentication
Two options:
1. **GitHub App (preferred):** Stronger scoping & rotation. Provide: `ClientID`, `InstallationID`, `PrivateKey`.
2. **PAT (personal access token):** Simpler but broader scoped.
The client automatically exchanges credentials for a registration token + admin token behind the scenes and refreshes them before expiry.
You can find more details on required permissions in the [GitHub Docs](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/authenticate-to-the-api).
GitHub Enterprise Server (GHES) is supported out of the box—just use your GHES URL when creating the client.
---
## Security Notes
- Always prefer GitHub App credentials; rotate PATs if you must use them.
- Treat JIT configs as secrets until consumed.
---
## Requirements
- Go 1.25 or later
---
## License
This project is licensed under the terms of the MIT open source license. Please refer to [LICENSE](./LICENSE) for the full terms.
---
## Maintainers
See [CODEOWNERS](./.github/CODEOWNERS) for the list of maintainers.
---
## Support
Please refer to [SUPPORT.md](./SUPPORT.md) for information on how to get help with this project.
+31
View File
@@ -0,0 +1,31 @@
Thanks for helping make GitHub safe for everyone.
# Security
GitHub takes the security of our software products and services seriously, including all of the open source code repositories managed through our GitHub organizations, such as [GitHub](https://github.com/GitHub).
Even though [open source repositories are outside of the scope of our bug bounty program](https://bounty.github.com/index.html#scope) and therefore not eligible for bounty rewards, we will ensure that your finding gets passed along to the appropriate maintainers for remediation.
## Reporting Security Issues
If you believe you have found a security vulnerability in any GitHub-owned repository, please report it to us through coordinated disclosure.
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Instead, please send an email to opensource-security[@]github.com.
Please include as much of the information listed below as you can to help us better understand and resolve the issue:
* The type of issue (e.g., buffer overflow, SQL injection, or cross-site scripting)
* Full paths of source file(s) related to the manifestation of the issue
* The location of the affected source code (tag/branch/commit or direct URL)
* Any special configuration required to reproduce the issue
* Step-by-step instructions to reproduce the issue
* Proof-of-concept or exploit code (if possible)
* Impact of the issue, including how an attacker might exploit the issue
This information will help us triage your report more quickly.
## Policy
See [GitHub's Safe Harbor Policy](https://docs.github.com/en/site-policy/security-policies/github-bug-bounty-program-legal-safe-harbor#1-safe-harbor-terms)
+13
View File
@@ -0,0 +1,13 @@
# Support
## How to file issues and get help
This project uses GitHub issues to track bugs and feature requests. Please search the existing issues before filing new issues to avoid duplicates. For new issues, file your bug or feature request as a new issue.
For help or questions about using this project, please open a new issue.
**actions/scaleset** is under active development and maintained by GitHub staff **AND THE COMMUNITY**. We will do our best to respond to support, feature requests, and community questions in a timely manner.
## GitHub Support Policy
Support for this project is limited to the resources listed above.
+1044
View File
File diff suppressed because it is too large Load Diff
+1364
View File
File diff suppressed because it is too large Load Diff
+188
View File
@@ -0,0 +1,188 @@
package scaleset
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"time"
"github.com/hashicorp/go-retryablehttp"
)
const (
headerActionsActivityID = "ActivityId"
headerGitHubRequestID = "X-GitHub-Request-Id"
)
type commonClient struct {
httpClient *http.Client
systemInfo SystemInfo // never set directly, use setSystemInfoUnlocked
userAgent string
httpClientOption
}
func newCommonClient(systemInfo SystemInfo, httpClientOption httpClientOption) *commonClient {
c := &commonClient{
httpClientOption: httpClientOption,
}
c.setSystemInfo(systemInfo)
retryableHTTPClient, err := httpClientOption.newRetryableHTTPClient()
if err != nil {
panic(fmt.Sprintf("failed to create retryable HTTP client: %v", err))
}
c.httpClient = retryableHTTPClient.StandardClient()
return c
}
func (c *commonClient) newRetryableHTTPClient() (*retryablehttp.Client, error) {
return c.httpClientOption.newRetryableHTTPClient()
}
func (c *commonClient) do(req *http.Request) (*http.Response, error) {
return sendRequest(c.httpClient, req)
}
// sendRequest ensures that the request is sent and the response body is fully read and closed.
// It trims the BOM when present in the response body.
//
// Make sure to use this function instead of http.Client.Do directly to avoid issues.
func sendRequest(c *http.Client, req *http.Request) (*http.Response, error) {
resp, err := c.Do(req)
if err != nil {
return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to send request: %w", err))
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to read the response body: %w", err))
}
if err := resp.Body.Close(); err != nil {
return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to close the response body: %w", err))
}
body = trimByteOrderMark(body)
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp, nil
}
type httpClientOption struct {
logger *slog.Logger
retryMax int
retryWaitMax time.Duration
rootCAs *x509.CertPool
tlsInsecureSkipVerify bool
proxyFunc ProxyFunc
}
func (o *httpClientOption) defaults() {
if o.logger == nil {
o.logger = slog.New(slog.DiscardHandler)
}
if o.retryMax == 0 {
o.retryMax = 4
}
if o.retryWaitMax == 0 {
o.retryWaitMax = 30 * time.Second
}
}
func (o *httpClientOption) newRetryableHTTPClient() (*retryablehttp.Client, error) {
retryClient := retryablehttp.NewClient()
retryClient.Logger = o.logger
retryClient.RetryMax = o.retryMax
retryClient.RetryWaitMax = o.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 o.rootCAs != nil {
transport.TLSClientConfig.RootCAs = o.rootCAs
}
if o.tlsInsecureSkipVerify {
transport.TLSClientConfig.InsecureSkipVerify = true
}
transport.Proxy = o.proxyFunc
retryClient.HTTPClient.Transport = transport
return retryClient, nil
}
func (c *commonClient) setSystemInfo(info SystemInfo) {
c.systemInfo = info
c.setUserAgent()
}
func (c *commonClient) setUserAgent() {
b, _ := json.Marshal(userAgent{
SystemInfo: c.systemInfo,
BuildVersion: buildInfo.version,
BuildCommitSHA: buildInfo.commitSHA,
Kind: "scaleset",
})
c.userAgent = string(b)
}
// HTTPOption defines a functional option for configuring the Client.
type HTTPOption func(*httpClientOption)
// WithLogger sets a custom logger for the Client.
func WithLogger(logger slog.Logger) HTTPOption {
return func(c *httpClientOption) {
c.logger = &logger
}
}
// WithRetryMax sets the maximum number of retries for the Client.
func WithRetryMax(retryMax int) HTTPOption {
return func(c *httpClientOption) {
c.retryMax = retryMax
}
}
// WithRetryWaitMax sets the maximum wait time between retries for the Client.
func WithRetryWaitMax(retryWaitMax time.Duration) HTTPOption {
return func(c *httpClientOption) {
c.retryWaitMax = retryWaitMax
}
}
// WithRootCAs sets custom root certificate authorities for the Client.
func WithRootCAs(rootCAs *x509.CertPool) HTTPOption {
return func(c *httpClientOption) {
c.rootCAs = rootCAs
}
}
// WithoutTLSVerify disables TLS certificate verification for the Client.
func WithoutTLSVerify() HTTPOption {
return func(c *httpClientOption) {
c.tlsInsecureSkipVerify = true
}
}
// WithProxy sets a custom proxy function for the Client.
func WithProxy(proxyFunc ProxyFunc) HTTPOption {
return func(c *httpClientOption) {
c.proxyFunc = proxyFunc
}
}
+155
View File
@@ -0,0 +1,155 @@
package scaleset
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/actions/scaleset/internal/testserver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/http/httpproxy"
)
func defaultHTTPClientOption() httpClientOption {
var opt httpClientOption
opt.defaults()
return opt
}
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 := newCommonClient(
testSystemInfo,
defaultHTTPClientOption(),
)
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 := newCommonClient(
testSystemInfo,
defaultHTTPClientOption(),
)
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))
}
})
}
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)
}
opts := defaultHTTPClientOption()
WithProxy(proxyFunc)(&opts)
client := newCommonClient(
testSystemInfo,
opts,
)
req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
require.NoError(t, err)
_, err = client.do(req)
require.NoError(t, err)
assert.True(t, serverCalled)
}
func TestUserAgent(t *testing.T) {
version, sha := detectModuleVersionAndCommit()
userAgentInfo := SystemInfo{
System: "actions-runner-controller",
Version: "0.1.0",
CommitSHA: "1234567890abcdef",
ScaleSetID: 10,
Subsystem: "test",
}
client := newCommonClient(
testSystemInfo,
defaultHTTPClientOption(),
)
got := client.userAgent
wantInfo := userAgent{
SystemInfo: testSystemInfo,
BuildCommitSHA: sha,
BuildVersion: version,
Kind: "scaleset",
}
b, err := json.Marshal(wantInfo)
require.NoError(t, err, "failed to marshal expected user agent")
want := string(b)
assert.Equal(t, want, got)
client.setSystemInfo(SystemInfo{
System: "actions-runner-controller",
Version: "0.1.0",
CommitSHA: "1234567890abcdef",
ScaleSetID: 10,
Subsystem: "test",
})
got = client.userAgent
wantInfo = userAgent{
SystemInfo: userAgentInfo,
BuildCommitSHA: sha,
BuildVersion: version,
Kind: "scaleset",
}
b, err = json.Marshal(wantInfo)
require.NoError(t, err, "failed to marshal expected user agent after SetSystemInfo")
want = string(b)
assert.Equal(t, want, got)
}
+109
View File
@@ -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, fmt.Errorf("failed to parse URL: %w", 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")
}
+195
View File
@@ -0,0 +1,195 @@
package scaleset
import (
"errors"
"net/url"
"os"
"strings"
"testing"
"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 *gitHubConfig
}{
{
configURL: "https://github.com/org/repo",
expected: &gitHubConfig{
scope: gitHubScopeRepository,
enterprise: "",
organization: "org",
repository: "repo",
isHosted: true,
},
},
{
configURL: "https://github.com/org/repo/",
expected: &gitHubConfig{
scope: gitHubScopeRepository,
enterprise: "",
organization: "org",
repository: "repo",
isHosted: true,
},
},
{
configURL: "https://github.com/org",
expected: &gitHubConfig{
scope: gitHubScopeOrganization,
enterprise: "",
organization: "org",
repository: "",
isHosted: true,
},
},
{
configURL: "https://github.com/enterprises/my-enterprise",
expected: &gitHubConfig{
scope: gitHubScopeEnterprise,
enterprise: "my-enterprise",
organization: "",
repository: "",
isHosted: true,
},
},
{
configURL: "https://github.com/enterprises/my-enterprise/",
expected: &gitHubConfig{
scope: gitHubScopeEnterprise,
enterprise: "my-enterprise",
organization: "",
repository: "",
isHosted: true,
},
},
{
configURL: "https://www.github.com/org",
expected: &gitHubConfig{
scope: gitHubScopeOrganization,
enterprise: "",
organization: "org",
repository: "",
isHosted: true,
},
},
{
configURL: "https://www.github.com/org/",
expected: &gitHubConfig{
scope: gitHubScopeOrganization,
enterprise: "",
organization: "org",
repository: "",
isHosted: true,
},
},
{
configURL: "https://github.localhost/org",
expected: &gitHubConfig{
scope: gitHubScopeOrganization,
enterprise: "",
organization: "org",
repository: "",
isHosted: true,
},
},
{
configURL: "https://my-ghes.com/org",
expected: &gitHubConfig{
scope: gitHubScopeOrganization,
enterprise: "",
organization: "org",
repository: "",
isHosted: false,
},
},
{
configURL: "https://my-ghes.com/org/",
expected: &gitHubConfig{
scope: gitHubScopeOrganization,
enterprise: "",
organization: "org",
repository: "",
isHosted: false,
},
},
{
configURL: "https://my-ghes.ghe.com/org/",
expected: &gitHubConfig{
scope: 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 := 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 := parseGitHubConfigFromURL(u)
require.Error(t, err)
assert.True(t, errors.Is(err, ErrInvalidGitHubConfigURL))
}
})
}
func TestGitHubConfig_GitHubAPIURL(t *testing.T) {
t.Run("when hosted", func(t *testing.T) {
config, err := 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 := 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 := 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 := 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())
})
}
+98
View File
@@ -0,0 +1,98 @@
package scaleset
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
type scalesetError string
func (e scalesetError) Error() string {
return string(e)
}
var (
RunnerNotFoundError = scalesetError("runner not found")
RunnerExistsError = scalesetError("runner exists")
JobStillRunningError = scalesetError("job still running")
MessageQueueTokenExpiredError = scalesetError("message queue token expired")
)
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)
}
// newRequestResponseError creates a detailed error message based on the HTTP request and response,
// including parsing the response body for known error formats.
//
// The sendRequest already parses errors using this method, so use this error if the client doesn't
// return an error, but the error is happening on the application logic level.
//
// Prefer creating errors using this function instead of manually constructing error messages since it automatically
// includes useful metadata like activity IDs and request IDs, and handles well-known error cases.
func newRequestResponseError(req *http.Request, resp *http.Response, err error) error {
var sb strings.Builder
fmt.Fprintf(&sb, "request %s %s failed", req.Method, req.URL.String())
if resp == nil {
return fmt.Errorf("%s: %w", sb.String(), err)
}
sb.WriteRune('(')
fmt.Fprintf(&sb, "status=%q", resp.Status)
if resp.Header.Get(headerActionsActivityID) != "" {
fmt.Fprintf(&sb, ", activity_id=%q", resp.Header.Get(headerActionsActivityID))
}
if resp.Header.Get(headerGitHubRequestID) != "" {
fmt.Fprintf(&sb, ", github_request_id=%q", resp.Header.Get(headerGitHubRequestID))
}
sb.WriteRune(')')
if resp.Body == nil || resp.ContentLength == 0 {
return fmt.Errorf("%s: %w: unknown error", sb.String(), err)
}
body, bodyErr := io.ReadAll(resp.Body)
if bodyErr != nil {
return fmt.Errorf("%s: %w: failed to read error response body: %w", sb.String(), err, bodyErr)
}
if len(body) == 0 {
return fmt.Errorf("%s: %w: unknown error", sb.String(), err)
}
var scalesetErr scalesetError
if errors.As(err, &scalesetErr) {
return fmt.Errorf("%s: %w: %s", sb.String(), err, string(body))
}
contentType := resp.Header.Get("Content-Type")
if len(contentType) > 0 && strings.Contains(contentType, "text/plain") {
return fmt.Errorf("%s: %w: %s", sb.String(), err, string(body))
}
var exception actionsExceptionError
if err := json.Unmarshal(body, &exception); err != nil {
return fmt.Errorf("%s: %w: failed to unmarshal error response body: %q", sb.String(), err, string(body))
}
switch {
case strings.Contains(exception.ExceptionName, "AgentExistsException"):
return fmt.Errorf("%s: %w: %s", sb.String(), RunnerExistsError, exception.Message)
case strings.Contains(exception.ExceptionName, "AgentNotFoundException"):
return fmt.Errorf("%s: %w: %s", sb.String(), RunnerNotFoundError, exception.Message)
case strings.Contains(exception.ExceptionName, "JobStillRunningException"):
return fmt.Errorf("%s: %w: %s", sb.String(), JobStillRunningError, exception.Message)
default:
return fmt.Errorf("%s: %w: %w", sb.String(), err, exception)
}
}
+253
View File
@@ -0,0 +1,253 @@
package scaleset
import (
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type readErrCloser struct{}
func (readErrCloser) Read([]byte) (int, error) { return 0, fmt.Errorf("read failed") }
func (readErrCloser) Close() error { return nil }
func TestActionsExceptionError(t *testing.T) {
t.Run("contains the exception name and message", func(t *testing.T) {
err := 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 TestNewRequestResponseError(t *testing.T) {
req := func(t *testing.T) *http.Request {
t.Helper()
u, err := url.Parse("https://example.com/org/repo")
require.NoError(t, err)
return &http.Request{Method: http.MethodGet, URL: u}
}
t.Run("resp is nil", func(t *testing.T) {
base := errors.New("base")
err := newRequestResponseError(req(t), nil, base)
require.Error(t, err)
assert.Contains(t, err.Error(), "request GET https://example.com/org/repo failed")
assert.True(t, errors.Is(err, base))
})
t.Run("resp body is nil", func(t *testing.T) {
base := errors.New("base")
resp := &http.Response{
Status: "500 Internal Server Error",
StatusCode: http.StatusInternalServerError,
ContentLength: 123,
Header: make(http.Header),
Body: nil,
}
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown error")
assert.True(t, errors.Is(err, base))
})
t.Run("empty body returns unknown error", func(t *testing.T) {
base := errors.New("base")
resp := &http.Response{
Status: "404 Not Found",
StatusCode: http.StatusNotFound,
ContentLength: 0,
Header: make(http.Header),
}
resp.Header.Set(headerActionsActivityID, "activity-id")
resp.Header.Set(headerGitHubRequestID, "request-id")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Contains(t, err.Error(), "status=\"404 Not Found\"")
assert.Contains(t, err.Error(), "activity_id=\"activity-id\"")
assert.Contains(t, err.Error(), "github_request_id=\"request-id\"")
assert.Contains(t, err.Error(), "unknown error")
assert.True(t, errors.Is(err, base))
})
t.Run("read body failure includes read error", func(t *testing.T) {
base := errors.New("base")
resp := &http.Response{
Status: "400 Bad Request",
StatusCode: http.StatusBadRequest,
ContentLength: 1,
Header: make(http.Header),
Body: io.NopCloser(readErrCloser{}),
}
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to read error response body")
assert.True(t, errors.Is(err, base))
assert.Contains(t, err.Error(), "read failed")
})
t.Run("unknown content length and empty body returns unknown error", func(t *testing.T) {
base := errors.New("base")
resp := &http.Response{
Status: "400 Bad Request",
StatusCode: http.StatusBadRequest,
ContentLength: -1,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
}
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown error")
assert.True(t, errors.Is(err, base))
})
t.Run("text/plain body is included", func(t *testing.T) {
base := errors.New("base")
body := "example plain text error"
resp := &http.Response{
Status: "400 Bad Request",
StatusCode: http.StatusBadRequest,
ContentLength: int64(len(body)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
resp.Header.Set("Content-Type", "text/plain")
resp.Header.Set(headerActionsActivityID, "activity-id")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Contains(t, err.Error(), body)
assert.True(t, errors.Is(err, base))
})
t.Run("scalesetError in error chain uses raw body (no JSON parsing)", func(t *testing.T) {
wrapped := fmt.Errorf("wrapped: %w", RunnerNotFoundError)
body := `{"typeName":"AgentExistsException","message":"should not be parsed"}`
resp := &http.Response{
Status: "404 Not Found",
StatusCode: http.StatusNotFound,
ContentLength: int64(len(body)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, wrapped)
require.Error(t, err)
assert.True(t, errors.Is(err, RunnerNotFoundError))
assert.Contains(t, err.Error(), body)
})
t.Run("known actions exception maps to sentinel error", func(t *testing.T) {
base := errors.New("base")
jsonBody := `{"typeName":"AgentExistsException","message":"runner already exists"}`
resp := &http.Response{
Status: "409 Conflict",
StatusCode: http.StatusConflict,
ContentLength: int64(len(jsonBody)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(jsonBody)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.True(t, errors.Is(err, RunnerExistsError))
assert.False(t, errors.Is(err, base), "base error should not be wrapped for mapped exceptions")
assert.Contains(t, err.Error(), "runner already exists")
})
t.Run("agent not found exception maps to sentinel error", func(t *testing.T) {
base := errors.New("base")
jsonBody := `{"typeName":"AgentNotFoundException","message":"missing"}`
resp := &http.Response{
Status: "404 Not Found",
StatusCode: http.StatusNotFound,
ContentLength: int64(len(jsonBody)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(jsonBody)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.True(t, errors.Is(err, RunnerNotFoundError))
assert.False(t, errors.Is(err, base))
assert.Contains(t, err.Error(), "missing")
})
t.Run("job still running exception maps to sentinel error", func(t *testing.T) {
base := errors.New("base")
jsonBody := `{"typeName":"JobStillRunningException","message":"still running"}`
resp := &http.Response{
Status: "409 Conflict",
StatusCode: http.StatusConflict,
ContentLength: int64(len(jsonBody)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(jsonBody)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.True(t, errors.Is(err, JobStillRunningError))
assert.False(t, errors.Is(err, base))
assert.Contains(t, err.Error(), "still running")
})
t.Run("invalid json returns unmarshal error and includes body", func(t *testing.T) {
base := errors.New("base")
bad := "not-json"
resp := &http.Response{
Status: "400 Bad Request",
StatusCode: http.StatusBadRequest,
ContentLength: int64(len(bad)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(bad)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.Contains(t, err.Error(), "failed to unmarshal error response body")
assert.Contains(t, err.Error(), "not-json")
assert.False(t, errors.Is(err, base), "base error is not wrapped on JSON unmarshal failures")
})
t.Run("unknown json error wraps exception", func(t *testing.T) {
base := errors.New("base")
jsonBody := `{"typeName":"SomeException","message":"example error message"}`
resp := &http.Response{
Status: "500 Internal Server Error",
StatusCode: http.StatusInternalServerError,
ContentLength: int64(len(jsonBody)),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(jsonBody)),
}
resp.Header.Set("Content-Type", "application/json")
err := newRequestResponseError(req(t), resp, base)
require.Error(t, err)
assert.True(t, errors.Is(err, base))
var ex actionsExceptionError
assert.True(t, errors.As(err, &ex))
assert.Equal(t, "SomeException", ex.ExceptionName)
assert.Equal(t, "example error message", ex.Message)
})
}
+55
View File
@@ -0,0 +1,55 @@
# Docker Runner Scale Set Example
This example showcases a Docker implementation of GitHub Actions runner scale sets, using the `github.com/actions/scaleset` client to provision ephemeral GitHub Actions runners as Docker containers.
The goal of this example is to show how simple and powerful it is when you only need to focus on the core logic of scaling runners up and down, while the client handles all the API interactions.
> [!WARNING]
> This is a simplified example meant for demonstration and learning purposes. It is not intended for production use.
> [!NOTE]
> When exiting normally all runners and the scale set itself are cleaned up automatically.
## Getting started
You can install the example with:
```bash
go install github.com/actions/scaleset/examples/dockerscaleset@latest
```
If this fails you should also try running the command with
```bash
GONOSUMDB=github.com/actions/scaleset GOPRIVATE=github.com/actions/scaleset go
install github.com/actions/scaleset/examples/dockerscaleset@latest
```
You'll then need:
- Docker installed and running on your machine.
- A URL for the target repository, organization, or enterprise where you want to register your scale set.
- [Credentials that have access to the above target](https://docs.github.com/en/actions/tutorials/use-actions-runner-controller/authenticate-to-the-api): you can use either a GitHub App (recommended) or a Personal Access Token (PAT).
- A name for your scale set (this must be unique within the runner group the scale set is created in).
---
## Flags
| Flag | Required | Description |
|------|----------|-------------|
| `--url` | Yes | Registration target (org, repo, or enterprise URL, e.g. `https://github.com/org/repo`). |
| `--name` | Yes | Runner scale set name (must be unique within the runner group). |
| `--labels` | No | Labels for workflow targeting (comma-separated or repeated). Defaults to `--name` if not provided. |
| `--max-runners` | No | Upper bound of concurrently provisioned runners (default 10). |
| `--min-runners` | No | Lower bound to maintain (default 0). |
| `--runner-group` | No | Runner group name (default `default`). |
| `--app-client-id` | Cond.* | GitHub App Client (App) ID. |
| `--app-installation-id` | Cond.* | GitHub App Installation ID. |
| `--app-private-key` | Cond.* | GitHub App private key PEM contents. |
| `--token` | Cond.* | Personal Access Token (alternative to App). |
| `--log-level` | No | `debug`, `info`, `warn`, `error` (default `info`). |
| `--log-format` | No | `text`, `json`, or `none` (any invalid → no logs). |
| `--runner-image` | No | Override container image (defaults to latest official). |
*Provide either App credentials (all three) OR a PAT.*
+141
View File
@@ -0,0 +1,141 @@
package main
import (
"fmt"
"log/slog"
"net/url"
"os"
"strings"
"github.com/actions/scaleset"
)
type Config struct {
RegistrationURL string
MaxRunners int
MinRunners int
ScaleSetName string
Labels []string
RunnerGroup string
GitHubApp scaleset.GitHubAppAuth
Token string
RunnerImage string
LogLevel string
LogFormat string
}
func (c *Config) defaults() {
if c.RunnerGroup == "" {
c.RunnerGroup = scaleset.DefaultRunnerGroup
}
if c.RunnerImage == "" {
c.RunnerImage = "ghcr.io/actions/actions-runner:latest"
}
}
func (c *Config) Validate() error {
c.defaults()
if _, err := url.ParseRequestURI(c.RegistrationURL); err != nil {
return fmt.Errorf("invalid registration URL: %w, it should be the full URL of where you want to register your scale set, e.g. 'https://github.com/org/repo'", err)
}
appError := c.GitHubApp.Validate()
if c.Token == "" && appError != nil {
return fmt.Errorf("no credentials provided: either GitHub App (client id, installation id and private key) (recommended) or a Personal Access Token are required")
}
if c.ScaleSetName == "" {
return fmt.Errorf("scale set name is required")
}
for i, label := range c.Labels {
if strings.TrimSpace(label) == "" {
return fmt.Errorf("label at index %d is empty", i)
}
}
if c.MaxRunners < c.MinRunners {
return fmt.Errorf("max runners cannot be less than min-runners")
}
if c.RunnerGroup == "" {
return fmt.Errorf("runner group is required")
}
if c.RunnerImage == "" {
return fmt.Errorf("runner image is required")
}
return nil
}
// systemInfo serves as a base system info
func systemInfo(scaleSetID int) scaleset.SystemInfo {
return scaleset.SystemInfo{
System: "dockerscaleset",
Subsystem: "dockerscaleset",
CommitSHA: "NA", // You can leverage build flags to set commit SHA
Version: "0.1.0", // You can leverage build flags to set version
ScaleSetID: scaleSetID,
}
}
func (c *Config) ScalesetClient() (*scaleset.Client, error) {
if err := c.GitHubApp.Validate(); err == nil {
return scaleset.NewClientWithGitHubApp(
scaleset.ClientWithGitHubAppConfig{
GitHubConfigURL: c.RegistrationURL,
GitHubAppAuth: c.GitHubApp,
SystemInfo: systemInfo(0),
},
)
}
return scaleset.NewClientWithPersonalAccessToken(
scaleset.NewClientWithPersonalAccessTokenConfig{
GitHubConfigURL: c.RegistrationURL,
PersonalAccessToken: c.Token,
SystemInfo: systemInfo(0),
},
)
}
func (c *Config) Logger() *slog.Logger {
var lvl slog.Level
switch strings.ToLower(c.LogLevel) {
case "debug":
lvl = slog.LevelDebug
case "info":
lvl = slog.LevelInfo
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
switch c.LogFormat {
case "json":
return slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: lvl,
}))
case "text":
return slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: lvl,
}))
default:
return slog.New(slog.DiscardHandler)
}
}
// BuildLabels returns the labels to use for the runner scale set.
// If custom labels are provided, those are used; otherwise, the scale set name is used as the label.
func (c *Config) BuildLabels() []scaleset.Label {
if len(c.Labels) > 0 {
labels := make([]scaleset.Label, len(c.Labels))
for i, name := range c.Labels {
labels[i] = scaleset.Label{Name: strings.TrimSpace(name)}
}
return labels
}
return []scaleset.Label{{Name: c.ScaleSetName}}
}
+322
View File
@@ -0,0 +1,322 @@
package main
import (
"bufio"
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/google/go-github/v79/github"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestE2E(t *testing.T) {
if os.Getenv("E2E") != "true" {
t.Skip("Skipping E2E test; set E2E=true to run")
}
configURL := mustGetEnv(t, "E2E_SCALESET_URL")
name := mustGetEnv(t, "E2E_SCALESET_NAME")
workflowEnv := mustE2EWorkflowEnv(t, name)
runArgs := mustE2ECommandArgs(t, configURL, name)
tempDir, err := os.MkdirTemp("", "e2e-dockerscaleset-")
require.NoError(t, err, "Failed to create temp dir")
defer os.RemoveAll(tempDir)
binaryPath := filepath.Join(tempDir, "dockerscaleset")
// Build the dockerscaleset binary in temp dir
{
cmd := exec.Command("go", "build", "-o", binaryPath, ".")
output, err := cmd.CombinedOutput()
require.NoError(t, err, "Failed to build dockerscaleset: %s", output)
}
// Fatal channel
testErrCh := make(chan error, 2)
runCmd := exec.Command(binaryPath, runArgs...)
stdout, err := runCmd.StdoutPipe()
runCmd.Stderr = os.Stderr
require.NoError(t, err, "Failed to get stdout pipe")
err = runCmd.Start()
require.NoError(t, err, "Failed to start dockerscaleset")
// Command exit error
cmdCh := make(chan error, 1)
t.Cleanup(func() {
_ = runCmd.Process.Signal(os.Interrupt)
<-cmdCh
})
// Wait for log line
waitCh := make(chan struct{}, 1)
var (
bufMu sync.Mutex
buf bytes.Buffer
)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
line := scanner.Text()
bufMu.Lock()
buf.WriteString(line + "\n")
bufMu.Unlock()
if strings.Contains(line, "Getting next message") {
close(waitCh)
break
}
}
if err := scanner.Err(); err != nil {
testErrCh <- fmt.Errorf("error reading dockerscaleset stdout: %w", err)
return
}
cmdCh <- runCmd.Wait()
close(cmdCh)
}()
runID, err := workflowEnv.triggerWorkflowDispatch(t, t.Context())
require.NoError(t, err, "Failed to trigger workflow")
statusCh := make(chan *WorkflowRun, 1)
go func() {
select {
case <-waitCh:
case <-time.After(30 * time.Second):
bufMu.Lock()
logs := buf.String()
bufMu.Unlock()
testErrCh <- fmt.Errorf("timeout waiting for dockerscaleset to be ready; logs:\n%s", logs)
return
}
status, err := workflowEnv.waitForWorkflowCompletion(t, t.Context(), runID, 10*time.Minute)
if err != nil {
testErrCh <- fmt.Errorf("failed to wait for workflow completion: %w", err)
return
}
statusCh <- status
}()
select {
case err := <-cmdCh:
select {
case status := <-statusCh:
assert.Equal(t, "completed", status.Status)
assert.Equal(t, "success", status.Conclusion)
case <-time.After(30 * time.Second):
bufMu.Lock()
logs := buf.String()
bufMu.Unlock()
t.Fatalf("Timeout waiting for workflow status after dockerscaleset exited\nexit: %v\nlogs:%s\n", err, logs)
}
case status := <-statusCh:
assert.NotNil(t, status, "WorkflowRun status is nil")
assert.Equal(t, "completed", status.Status)
assert.Equal(t, "success", status.Conclusion)
return
case err := <-testErrCh:
t.Fatal(err)
}
}
type e2eWorkflowEnv struct {
targetOrg string
targetRepo string
targetFile string
scalesetName string
client *github.Client
}
func mustE2EWorkflowEnv(t *testing.T, scalesetName string) *e2eWorkflowEnv {
return &e2eWorkflowEnv{
targetOrg: mustGetEnv(t, "E2E_WORKFLOW_TARGET_ORG"),
targetRepo: mustGetEnv(t, "E2E_WORKFLOW_TARGET_REPO"),
targetFile: mustGetEnv(t, "E2E_WORKFLOW_TARGET_FILE"),
scalesetName: scalesetName,
client: github.NewClient(nil).WithAuthToken(mustGetEnv(t, "E2E_WORKFLOW_GITHUB_TOKEN")),
}
}
func mustE2ECommandArgs(t *testing.T, configURL, name string) []string {
args := []string{
"--url", configURL,
"--name", name,
"--log-level", "debug",
}
// GitHub App credentials
var (
clientID string
installationID int
privateKeyPath string
)
// GitHub token
var token string
clientID = os.Getenv("E2E_SCALESET_GITHUB_APP_CLIENT_ID")
installationIDStr := os.Getenv("E2E_SCALESET_GITHUB_APP_INSTALLATION_ID")
privateKeyPath = os.Getenv("E2E_SCALESET_GITHUB_APP_PRIVATE_KEY_PATH")
if clientID != "" && installationIDStr != "" && privateKeyPath != "" {
id, err := strconv.Atoi(installationIDStr)
require.NoError(t, err, "Invalid E2E_SCALESET_GITHUB_APP_INSTALLATION_ID")
installationID = id
args = append(args,
"--app-client-id", clientID,
"--app-installation-id", fmt.Sprintf("%d", installationID),
"--app-private-key", privateKeyPath,
)
} else {
token = os.Getenv("E2E_SCALESET_GITHUB_TOKEN")
require.NotEmpty(t, token, "E2E_SCALESET_GITHUB_TOKEN must be set if GitHub App credentials are not provided")
args = append(args,
"--token", token,
)
}
runnerGroup := os.Getenv("E2E_SCALESET_RUNNER_GROUP")
if runnerGroup != "" {
args = append(args,
"--runner-group", runnerGroup,
)
}
minRunners := 0
if minRunnersStr := os.Getenv("E2E_SCALESET_MIN_RUNNERS"); minRunnersStr != "" {
m, err := strconv.Atoi(minRunnersStr)
require.NoError(t, err, "Invalid E2E_SCALESET_MIN_RUNNERS")
minRunners = m
require.GreaterOrEqual(t, minRunners, 0, "E2E_SCALESET_MIN_RUNNERS must be >= 0")
}
maxRunners := 10
if maxRunnersStr := os.Getenv("E2E_SCALESET_MAX_RUNNERS"); maxRunnersStr != "" {
m, err := strconv.Atoi(maxRunnersStr)
require.NoError(t, err, "Invalid E2E_SCALESET_MAX_RUNNERS")
maxRunners = m
require.GreaterOrEqual(t, maxRunners, 0, "E2E_SCALESET_MAX_RUNNERS must be >= 0")
}
require.GreaterOrEqual(t, maxRunners, minRunners, "E2E_SCALESET_MAX_RUNNERS must be >= E2E_SCALESET_MIN_RUNNERS")
args = append(args,
"--min-runners", strconv.Itoa(minRunners),
"--max-runners", strconv.Itoa(maxRunners),
)
return args
}
type WorkflowRun struct {
ID int `json:"id"`
Status string `json:"status"`
Conclusion string `json:"conclusion"`
CreatedAt string `json:"created_at"`
}
func (env *e2eWorkflowEnv) triggerWorkflowDispatch(t *testing.T, ctx context.Context) (int, error) {
dispatchTime := time.Now().UTC()
resp, err := env.client.Actions.CreateWorkflowDispatchEventByFileName(
ctx,
env.targetOrg,
env.targetRepo,
env.targetFile,
github.CreateWorkflowDispatchEventRequest{
Ref: "main",
Inputs: map[string]any{
"scaleset_name": env.scalesetName,
},
},
)
require.NoError(t, err, "Failed to create workflow dispatch")
require.Equal(t, 204, resp.StatusCode, "Unexpected status code from workflow dispatch")
// Wait a bit for the run to be created
time.Sleep(10 * time.Second)
// List runs with event=workflow_dispatch and since=dispatchTime
opts := &github.ListWorkflowRunsOptions{
Event: "workflow_dispatch",
Created: ">=" + dispatchTime.Format(time.RFC3339),
ListOptions: github.ListOptions{
PerPage: 10,
},
}
runs, _, err := env.client.Actions.ListWorkflowRunsByFileName(
t.Context(),
env.targetOrg,
env.targetRepo,
env.targetFile,
opts,
)
require.NoError(t, err, "Failed to list workflow runs")
require.Greater(t, len(runs.WorkflowRuns), 0, "No workflow runs found after dispatch")
// Sort by created_at desc, take the first (most recent)
var latestRun *github.WorkflowRun
var latestTime time.Time
for _, run := range runs.WorkflowRuns {
createdAt := run.CreatedAt.Time
if createdAt.After(latestTime) {
latestTime = createdAt
latestRun = run
}
}
if latestRun == nil {
return 0, fmt.Errorf("no workflow runs found after dispatch")
}
return int(latestRun.GetID()), nil
}
func (env *e2eWorkflowEnv) waitForWorkflowCompletion(t *testing.T, ctx context.Context, runID int, timeout time.Duration) (*WorkflowRun, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-ticker.C:
run, _, err := env.client.Actions.GetWorkflowRunByID(ctx, env.targetOrg, env.targetRepo, int64(runID))
require.NoError(t, err, "Failed to get workflow run by ID")
if run.GetStatus() == "completed" {
return &WorkflowRun{
ID: int(run.GetID()),
Status: run.GetStatus(),
Conclusion: run.GetConclusion(),
CreatedAt: run.GetCreatedAt().Format(time.RFC3339),
}, nil
}
}
}
}
func mustGetEnv(t *testing.T, key string) string {
value := os.Getenv(key)
if value == "" {
t.Fatalf("Environment variable %s not set", key)
}
return value
}
+193
View File
@@ -0,0 +1,193 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"os"
"os/signal"
"github.com/actions/scaleset"
"github.com/actions/scaleset/listener"
"github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client"
"github.com/google/uuid"
"github.com/spf13/cobra"
)
func init() {
flags := cmd.Flags()
flags.StringVar(&cfg.RegistrationURL, "url", "", "REQUIRED: URL where to register your scale set (e.g. https://github.com/org/repo)")
flags.IntVar(&cfg.MaxRunners, "max-runners", 10, "Maximum number of runners")
flags.IntVar(&cfg.MinRunners, "min-runners", 0, "Minimum number of runners")
flags.StringVar(&cfg.ScaleSetName, "name", "", "REQUIRED: Name of your scale set")
flags.StringSliceVar(&cfg.Labels, "labels", nil, "Labels for workflow targeting (comma-separated or repeated). Defaults to --name if not provided.")
flags.StringVar(&cfg.RunnerGroup, "runner-group", scaleset.DefaultRunnerGroup, "Name of the runner group your scale set should belong to")
flags.StringVar(&cfg.GitHubApp.ClientID, "app-client-id", "", "GitHub App client id")
flags.Int64Var(&cfg.GitHubApp.InstallationID, "app-installation-id", 0, "GitHub App installation ID")
flags.StringVar(&cfg.GitHubApp.PrivateKey, "app-private-key", "", "GitHub App private key")
flags.StringVar(&cfg.Token, "token", "", "Personal access token (can be used in place of a GitHub App, although not recommended)")
flags.StringVar(&cfg.LogLevel, "log-level", "info", "Logging level (debug, info, warn, error)")
flags.StringVar(&cfg.LogFormat, "log-format", "text", "Logging format (text, json). If invalid value is provided, defaults to no logs.")
if err := cmd.MarkFlagRequired("url"); err != nil {
panic(err)
}
if err := cmd.MarkFlagRequired("name"); err != nil {
panic(err)
}
}
func main() {
if err := cmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
os.Exit(1)
}
}
func run(ctx context.Context, c Config) error {
// Ensure that the config is valid
if err := c.Validate(); err != nil {
return fmt.Errorf("configuration validation failed: %w", err)
}
logger := c.Logger()
// Create a new scaleset scalesetClient
scalesetClient, err := c.ScalesetClient()
if err != nil {
return fmt.Errorf("failed to create scaleset client: %w", err)
}
// Get the runner group ID of the chosen runner group
var runnerGroupID int
switch c.RunnerGroup {
case scaleset.DefaultRunnerGroup:
runnerGroupID = 1
default:
runnerGroup, err := scalesetClient.GetRunnerGroupByName(ctx, c.RunnerGroup)
if err != nil {
return fmt.Errorf("failed to get runner group ID: %w", err)
}
runnerGroupID = runnerGroup.ID
}
// Create the runner scale set
scaleSet, err := scalesetClient.CreateRunnerScaleSet(ctx, &scaleset.RunnerScaleSet{
Name: c.ScaleSetName,
RunnerGroupID: runnerGroupID,
Labels: c.BuildLabels(),
RunnerSetting: scaleset.RunnerSetting{
DisableUpdate: true,
},
})
if err != nil {
return fmt.Errorf("failed to create runner scale set: %w", err)
}
// Set the user agent for the scaleset client now that we have the scale set ID
scalesetClient.SetSystemInfo(systemInfo(scaleSet.ID))
defer func() {
logger.Info(
"Deleting runner scale set",
slog.Int("scaleSetID", scaleSet.ID),
)
if err := scalesetClient.DeleteRunnerScaleSet(context.WithoutCancel(ctx), scaleSet.ID); err != nil {
slog.Error(
"Failed to delete runner scale set",
slog.Int("scaleSetID", scaleSet.ID),
slog.String("error", err.Error()),
)
}
}()
dockerClient, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
if err != nil {
return fmt.Errorf("failed to create docker client: %w", err)
}
logger.Info(
"Pulling runner image",
slog.String("image", c.RunnerImage),
)
// Pull the runner image
pull, err := dockerClient.ImagePull(ctx, c.RunnerImage, image.PullOptions{})
if err != nil {
return fmt.Errorf("failed to pull runner image: %w", err)
}
if _, err := io.ReadAll(pull); err != nil {
return fmt.Errorf("failed to read image pull response: %w", err)
}
if err := pull.Close(); err != nil {
return fmt.Errorf("failed to close image pull: %w", err)
}
// Get the name of the client which will be used as the owner
hostname, err := os.Hostname()
if err != nil {
hostname = uuid.NewString()
logger.Info("Failed to get hostname, fallback to uuid", "uuid", hostname, "error", err)
}
sessionClient, err := scalesetClient.MessageSessionClient(ctx, scaleSet.ID, hostname)
if err != nil {
return fmt.Errorf("failed to create message session client: %w", err)
}
defer sessionClient.Close(context.Background())
logger.Info("Initializing listener")
listener, err := listener.New(sessionClient, listener.Config{
ScaleSetID: scaleSet.ID,
MaxRunners: c.MaxRunners,
Logger: logger.WithGroup("listener"),
})
if err != nil {
return fmt.Errorf("failed to create listener: %w", err)
}
scaler := &Scaler{
logger: logger.WithGroup("scaler"),
runners: runnerState{
idle: make(map[string]string),
busy: make(map[string]string),
},
runnerImage: c.RunnerImage,
minRunners: c.MinRunners,
maxRunners: c.MaxRunners,
dockerClient: dockerClient,
scalesetClient: scalesetClient,
scaleSetID: scaleSet.ID,
}
defer scaler.shutdown(context.WithoutCancel(ctx))
logger.Info("Starting listener")
if err := listener.Run(ctx, scaler); !errors.Is(err, context.Canceled) {
return fmt.Errorf("listener run failed: %w", err)
}
return nil
}
var cfg Config
var cmd = &cobra.Command{
Use: "dockerscaleset",
Short: "Example CLI application scaling runners using Docker",
Long: `This is an example CLI application that demonstrates how to scale
runners using Docker.`,
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt)
defer cancel()
if err := cfg.Validate(); err != nil {
return fmt.Errorf("invalid configuration: %w", err)
}
return run(ctx, cfg)
},
}
+195
View File
@@ -0,0 +1,195 @@
package main
import (
"context"
"fmt"
"log/slog"
"sync"
"github.com/actions/scaleset"
"github.com/actions/scaleset/listener"
"github.com/docker/docker/api/types/container"
dockerclient "github.com/docker/docker/client"
"github.com/google/uuid"
)
type Scaler struct {
runners runnerState
runnerImage string
scaleSetID int
dockerClient *dockerclient.Client
scalesetClient *scaleset.Client
minRunners int
maxRunners int
logger *slog.Logger
}
func (a *Scaler) HandleDesiredRunnerCount(ctx context.Context, count int) (int, error) {
currentCount := a.runners.count()
targetRunnerCount := min(a.maxRunners, a.minRunners+count)
switch {
case targetRunnerCount == currentCount:
// No scaling needed
return currentCount, nil
case targetRunnerCount > currentCount:
// Scale up
scaleUp := targetRunnerCount - currentCount
a.logger.Info(
"Scaling up runners",
slog.Int("currentCount", currentCount),
slog.Int("desiredCount", targetRunnerCount),
slog.Int("scaleUp", scaleUp),
)
for range scaleUp {
if _, err := a.startRunner(ctx); err != nil {
return 0, fmt.Errorf("failed to start runner: %w", err)
}
}
return a.runners.count(), nil
default:
// No need to handle scale down events, since:
// 1. JobCompleted events will first remove runners
// 2. If the count is still below the current runner count, the JobCompleted event will be delivered in the next batch.
// 3. Removal after JobCompleted events is handled synchronously.
// 4. If the job is cancelled, the JobCompleted event will still be delivered.
}
return a.runners.count(), nil
}
func (a *Scaler) HandleJobStarted(ctx context.Context, jobInfo *scaleset.JobStarted) error {
a.logger.Info(
"Job started",
slog.Int64("runnerRequestId", jobInfo.RunnerRequestID),
slog.String("jobId", jobInfo.JobID),
)
a.runners.markBusy(jobInfo.RunnerName)
return nil
}
func (a *Scaler) HandleJobCompleted(ctx context.Context, jobInfo *scaleset.JobCompleted) error {
a.logger.Info("Job completed", slog.Int64("runnerRequestId", jobInfo.RunnerRequestID), slog.String("jobId", jobInfo.JobID))
containerID := a.runners.markDone(jobInfo.RunnerName)
if err := a.dockerClient.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil {
return fmt.Errorf("failed to remove runner container: %w", err)
}
return nil
}
func (a *Scaler) startRunner(ctx context.Context) (string, error) {
name := fmt.Sprintf("runner-%s", uuid.NewString()[:8])
jit, err := a.scalesetClient.GenerateJitRunnerConfig(
ctx,
&scaleset.RunnerScaleSetJitRunnerSetting{
Name: name,
},
a.scaleSetID,
)
if err != nil {
return "", fmt.Errorf("failed to generate JIT config: %w", err)
}
c, err := a.dockerClient.ContainerCreate(
ctx,
&container.Config{
Image: a.runnerImage,
User: "runner",
Cmd: []string{"/home/runner/run.sh"},
Env: []string{
fmt.Sprintf("ACTIONS_RUNNER_INPUT_JITCONFIG=%s", jit.EncodedJITConfig),
},
},
nil,
nil, nil,
name,
)
if err != nil {
return "", fmt.Errorf("failed to create runner container: %w", err)
}
if err := a.dockerClient.ContainerStart(ctx, c.ID, container.StartOptions{}); err != nil {
return "", fmt.Errorf("failed to start runner container: %w", err)
}
a.runners.addIdle(name, c.ID)
return name, nil
}
func (a *Scaler) shutdown(ctx context.Context) {
a.logger.Info("Shutting down runners")
a.runners.mu.Lock()
defer a.runners.mu.Unlock()
for name, containerID := range a.runners.idle {
a.logger.Info("Removing idle runner", slog.String("name", name), slog.String("containerID", containerID))
if err := a.dockerClient.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil {
a.logger.Error("Failed to remove idle runner container", slog.String("name", name), slog.String("containerID", containerID), slog.String("error", err.Error()))
}
}
clear(a.runners.idle)
for name, containerID := range a.runners.busy {
a.logger.Info("Removing busy runner", slog.String("name", name), slog.String("containerID", containerID))
if err := a.dockerClient.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil {
a.logger.Error("Failed to remove busy runner container", slog.String("name", name), slog.String("containerID", containerID), slog.String("error", err.Error()))
}
}
clear(a.runners.busy)
}
var _ listener.Scaler = (*Scaler)(nil)
type runnerState struct {
mu sync.Mutex
idle map[string]string
busy map[string]string
}
func (r *runnerState) count() int {
r.mu.Lock()
count := len(r.idle) + len(r.busy)
r.mu.Unlock()
return count
}
func (r *runnerState) markBusy(name string) {
r.mu.Lock()
defer r.mu.Unlock()
state, ok := r.idle[name]
if !ok {
panic("marking non-existent runner busy")
}
delete(r.idle, name)
r.busy[name] = state
}
func (r *runnerState) markDone(name string) string {
r.mu.Lock()
defer r.mu.Unlock()
return r.markDoneUnlocked(name)
}
func (r *runnerState) markDoneUnlocked(name string) string {
containerID, ok := r.busy[name]
if ok {
delete(r.busy, name)
return containerID
}
containerID, ok = r.idle[name]
if ok {
delete(r.idle, name)
return containerID
}
panic("marking non-existent runner done")
}
func (r *runnerState) addIdle(name, containerID string) {
r.mu.Lock()
r.idle[name] = containerID
r.mu.Unlock()
}
+89
View File
@@ -0,0 +1,89 @@
module github.com/actions/scaleset
go 1.25.3
require (
github.com/docker/docker v28.5.2+incompatible
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/go-github/v79 v79.0.0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
golang.org/x/net v0.47.0
)
require (
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/brunoga/deep v1.2.4 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/yaml v0.1.0 // indirect
github.com/knadh/koanf/providers/env v1.0.0 // indirect
github.com/knadh/koanf/providers/file v1.1.2 // indirect
github.com/knadh/koanf/providers/posflag v0.1.0 // indirect
github.com/knadh/koanf/providers/structs v0.1.0 // indirect
github.com/knadh/koanf/v2 v2.3.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/vektra/mockery/v3 v3.6.1 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
go.opentelemetry.io/otel v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
go.opentelemetry.io/otel/metric v1.35.0 // indirect
go.opentelemetry.io/otel/trace v1.35.0 // indirect
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect
golang.org/x/term v0.37.0 // indirect
golang.org/x/text v0.31.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/grpc v1.72.2 // indirect
google.golang.org/protobuf v1.36.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools/v3 v3.5.2 // indirect
)
tool (
github.com/vektra/mockery/v3
golang.org/x/tools/cmd/deadcode
)
+206
View File
@@ -0,0 +1,206 @@
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/brunoga/deep v1.2.4 h1:Aj9E9oUbE+ccbyh35VC/NHlzzjfIVU69BXu2mt2LmL8=
github.com/brunoga/deep v1.2.4/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
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/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
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.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/go-github/v79 v79.0.0 h1:MdodQojuFPBhmtwHiBcIGLw/e/wei2PvFX9ndxK0X4Y=
github.com/google/go-github/v79 v79.0.0/go.mod h1:OAFbNhq7fQwohojb06iIIQAB9CBGYLq999myfUFnrS4=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
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/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
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/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo=
github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w=
github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY=
github.com/knadh/koanf/providers/env v1.0.0 h1:ufePaI9BnWH+ajuxGGiJ8pdTG0uLEUWC7/HDDPGLah0=
github.com/knadh/koanf/providers/env v1.0.0/go.mod h1:mzFyRZueYhb37oPmC1HAv/oGEEuyvJDA98r3XAa8Gak=
github.com/knadh/koanf/providers/file v1.1.2 h1:aCC36YGOgV5lTtAFz2qkgtWdeQsgfxUkxDOe+2nQY3w=
github.com/knadh/koanf/providers/file v1.1.2/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI=
github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U=
github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0=
github.com/knadh/koanf/providers/structs v0.1.0 h1:wJRteCNn1qvLtE5h8KQBvLJovidSdntfdyIbbCzEyE0=
github.com/knadh/koanf/providers/structs v0.1.0/go.mod h1:sw2YZ3txUcqA3Z27gPlmmBzWn1h8Nt9O6EP/91MkcWE=
github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM=
github.com/knadh/koanf/v2 v2.3.0/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
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/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.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
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/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
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/vektra/mockery/v3 v3.6.1 h1:YyqAXihdNML8y6SJnvPKYr+2HAHvBjdvqFu/fMYlX8g=
github.com/vektra/mockery/v3 v3.6.1/go.mod h1:Oti3Df0WP8wwT31yuVri3QNsDeMUQU5Q4QEg8EabaBw=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 h1:E2/AqCUMZGgd73TQkxUMcMla25GB9i/5HOdLr+uH7Vo=
golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/grpc v1.72.2 h1:TdbGzwb82ty4OusHWepvFWGLgIbNo1/SUynEN0ssqv8=
google.golang.org/grpc v1.72.2/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+156
View File
@@ -0,0 +1,156 @@
package testserver
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/golang-jwt/jwt/v4"
"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.
func New(t testing.TB, handler http.Handler, options ...actionsServerOption) *actionsServer {
s := NewUnstarted(t, handler, options...)
s.Start()
return s
}
func NewUnstarted(t testing.TB, 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 testing.TB) {
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 testing.TB) 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-----`
+183
View File
@@ -0,0 +1,183 @@
// Package listener provides a listener for GitHub Actions runner scale set messages.
package listener
import (
"context"
"errors"
"fmt"
"log/slog"
"math"
"sync/atomic"
"github.com/actions/scaleset"
"github.com/google/uuid"
)
// Config holds the configuration for the Listener.
type Config struct {
// ScaleSetID is the ID of the runner scale set to listen to.
ScaleSetID int
// MaxRunners is the capacity of runners that can be handled at once.
MaxRunners int
// Logger is the logger to use for logging. Default is a no-op logger.
Logger *slog.Logger
}
func (c *Config) defaults() {
if c.Logger == nil {
c.Logger = slog.New(slog.DiscardHandler)
}
}
// Validate returns an error if the configuration is invalid.
func (c *Config) Validate() error {
c.defaults()
if c.ScaleSetID == 0 {
return errors.New("scaleSetID is required")
}
if c.MaxRunners < 0 || c.MaxRunners > math.MaxInt32 {
return errors.New("maxRunners must be between 0 and MaxInt32")
}
return nil
}
// Client defines the interface for communicating with the scaleset API.
// In most cases, it should be scaleset.Client from the scaleset package.
// This interface is defined to allow for easier testing and mocking, as well
// as allowing wrappers around the scaleset client if needed.
type Client interface {
GetMessage(ctx context.Context, lastMessageID, maxCapacity int) (*scaleset.RunnerScaleSetMessage, error)
DeleteMessage(ctx context.Context, messageID int) error
Session() scaleset.RunnerScaleSetSession
}
type Option func(*Listener)
// Listener listens for messages from the scaleset service and handles them. It automatically handles session
// creation/deletion/refreshing and message polling and acking.
type Listener struct {
// The main client responsible for communicating with the scaleset service
client Client
// Configuration for the listener
scaleSetID int
maxRunners atomic.Uint32
// configuration for the listener
logger *slog.Logger
}
// SetMaxRunners sets the capacity of the scaleset. It is concurrently
// safe to update the max runners during listener.Run.
func (l *Listener) SetMaxRunners(count int) {
l.maxRunners.Store(uint32(count))
}
// New creates a new Listener with the given configuration.
func New(client Client, config Config, options ...Option) (*Listener, error) {
if client == nil {
return nil, errors.New("client is required")
}
if err := config.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
listener := &Listener{
client: client,
scaleSetID: config.ScaleSetID,
logger: config.Logger,
}
listener.SetMaxRunners(config.MaxRunners)
return listener, nil
}
// Scaler defines the interface for handling scale set messages.
type Scaler interface {
HandleJobStarted(ctx context.Context, jobInfo *scaleset.JobStarted) error
HandleJobCompleted(ctx context.Context, jobInfo *scaleset.JobCompleted) error
HandleDesiredRunnerCount(ctx context.Context, count int) (int, error)
}
// Run starts the listener and processes messages using the provided scaler.
func (l *Listener) Run(ctx context.Context, scaler Scaler) error {
{
initialSession := l.client.Session()
if initialSession.SessionID == uuid.Nil {
return fmt.Errorf("initial session is nil")
}
if initialSession.Statistics == nil {
return fmt.Errorf("session statistics is nil")
}
l.logger.Info(
"Handling initial session statistics",
slog.Int("totalAssignedJobs", initialSession.Statistics.TotalAssignedJobs),
)
if _, err := scaler.HandleDesiredRunnerCount(ctx, initialSession.Statistics.TotalAssignedJobs); err != nil {
return fmt.Errorf("handling initial message failed: %w", err)
}
}
var lastMessageID int
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
l.logger.Info("Getting next message", slog.Int("lastMessageID", lastMessageID))
msg, err := l.client.GetMessage(
ctx,
lastMessageID,
int(l.maxRunners.Load()),
)
if err != nil {
return fmt.Errorf("failed to get message: %w", err)
}
if msg == nil {
_, err := scaler.HandleDesiredRunnerCount(ctx, 0)
if err != nil {
return fmt.Errorf("handling nil message failed: %w", err)
}
continue
}
lastMessageID = msg.MessageID
// Remove cancellation from the context to avoid cancelling the message handling.
if err := l.handleMessage(context.WithoutCancel(ctx), scaler, msg); err != nil {
return fmt.Errorf("failed to handle message: %w", err)
}
}
}
func (l *Listener) handleMessage(ctx context.Context, handler Scaler, msg *scaleset.RunnerScaleSetMessage) error {
if err := l.client.DeleteMessage(ctx, msg.MessageID); err != nil {
return fmt.Errorf("failed to delete message: %w", err)
}
for _, jobStarted := range msg.JobStartedMessages {
if err := handler.HandleJobStarted(ctx, jobStarted); err != nil {
return fmt.Errorf("failed to handle job started: %w", err)
}
}
for _, jobCompleted := range msg.JobCompletedMessages {
if err := handler.HandleJobCompleted(ctx, jobCompleted); err != nil {
return fmt.Errorf("failed to handle job completed: %w", err)
}
}
if _, err := handler.HandleDesiredRunnerCount(ctx, msg.Statistics.TotalAssignedJobs); err != nil {
return fmt.Errorf("failed to handle desired runner count: %w", err)
}
return nil
}
+183
View File
@@ -0,0 +1,183 @@
package listener
import (
"context"
"math"
"testing"
"github.com/actions/scaleset"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestNew(t *testing.T) {
t.Parallel()
t.Run("invalid config", func(t *testing.T) {
t.Parallel()
var config Config
assert.Error(t, config.Validate())
})
t.Run("valid config", func(t *testing.T) {
t.Parallel()
config := Config{
ScaleSetID: 1,
}
assert.NoError(t, config.Validate())
})
t.Run("invalid max runners", func(t *testing.T) {
t.Parallel()
config := Config{
ScaleSetID: 1,
MaxRunners: -1,
}
assert.Error(t, config.Validate())
})
t.Run("zero max runners", func(t *testing.T) {
t.Parallel()
config := Config{
ScaleSetID: 1,
MaxRunners: math.MaxInt32 + 1,
}
assert.Error(t, config.Validate())
})
t.Run("creates listener", func(t *testing.T) {
t.Parallel()
config := Config{
ScaleSetID: 1,
MaxRunners: 5,
}
client := NewMockClient(t)
l, err := New(client, config)
require.Nil(t, err)
assert.Equal(t, config.ScaleSetID, l.scaleSetID)
assert.Equal(t, uint32(config.MaxRunners), l.maxRunners.Load())
})
}
func TestListener_Run(t *testing.T) {
t.Parallel()
t.Run("call handle regardless of initial message", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
config := Config{
ScaleSetID: 1,
}
client := NewMockClient(t)
uuid := uuid.New()
session := scaleset.RunnerScaleSetSession{
SessionID: uuid,
OwnerName: "example",
RunnerScaleSet: &scaleset.RunnerScaleSet{},
MessageQueueURL: "https://example.com",
MessageQueueAccessToken: "1234567890",
Statistics: &scaleset.RunnerScaleSetStatistic{},
}
client.On("Session").Return(session).Once()
l, err := New(client, config)
require.Nil(t, err)
var called bool
handler := NewMockScaler(t)
handler.On(
"HandleDesiredRunnerCount",
mock.Anything,
mock.Anything,
).
Return(0, nil).
Run(
func(mock.Arguments) {
called = true
cancel()
},
).
Once()
err = l.Run(ctx, handler)
assert.ErrorIs(t, err, context.Canceled)
assert.True(t, called)
})
t.Run("cancel context after get message", func(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
config := Config{
ScaleSetID: 1,
MaxRunners: 10,
}
client := NewMockClient(t)
uuid := uuid.New()
session := scaleset.RunnerScaleSetSession{
SessionID: uuid,
OwnerName: "example",
RunnerScaleSet: &scaleset.RunnerScaleSet{},
MessageQueueURL: "https://example.com",
MessageQueueAccessToken: "1234567890",
Statistics: &scaleset.RunnerScaleSetStatistic{},
}
msg := &scaleset.RunnerScaleSetMessage{
MessageID: 1,
Statistics: &scaleset.RunnerScaleSetStatistic{},
}
client.On("Session").Return(session).Once()
client.On(
"GetMessage",
ctx,
mock.Anything,
10,
).
Return(msg, nil).
Run(
func(mock.Arguments) {
cancel()
},
).
Once()
// Ensure delete message is called without cancel
client.On(
"DeleteMessage",
context.WithoutCancel(ctx),
mock.Anything,
).Return(nil).Once()
handler := NewMockScaler(t)
handler.On(
"HandleDesiredRunnerCount",
mock.Anything,
0,
).
Return(0, nil).
Once()
handler.On(
"HandleDesiredRunnerCount",
mock.Anything,
mock.Anything,
).
Return(0, nil).
Once()
l, err := New(client, config)
require.Nil(t, err)
err = l.Run(ctx, handler)
assert.ErrorIs(t, context.Canceled, err)
})
}
+421
View File
@@ -0,0 +1,421 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package listener
import (
"context"
"github.com/actions/scaleset"
mock "github.com/stretchr/testify/mock"
)
// NewMockClient creates a new instance of MockClient. 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 NewMockClient(t interface {
mock.TestingT
Cleanup(func())
}) *MockClient {
mock := &MockClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockClient is an autogenerated mock type for the Client type
type MockClient struct {
mock.Mock
}
type MockClient_Expecter struct {
mock *mock.Mock
}
func (_m *MockClient) EXPECT() *MockClient_Expecter {
return &MockClient_Expecter{mock: &_m.Mock}
}
// DeleteMessage provides a mock function for the type MockClient
func (_mock *MockClient) DeleteMessage(ctx context.Context, messageID int) error {
ret := _mock.Called(ctx, messageID)
if len(ret) == 0 {
panic("no return value specified for DeleteMessage")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, int) error); ok {
r0 = returnFunc(ctx, messageID)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockClient_DeleteMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteMessage'
type MockClient_DeleteMessage_Call struct {
*mock.Call
}
// DeleteMessage is a helper method to define mock.On call
// - ctx context.Context
// - messageID int
func (_e *MockClient_Expecter) DeleteMessage(ctx interface{}, messageID interface{}) *MockClient_DeleteMessage_Call {
return &MockClient_DeleteMessage_Call{Call: _e.mock.On("DeleteMessage", ctx, messageID)}
}
func (_c *MockClient_DeleteMessage_Call) Run(run func(ctx context.Context, messageID int)) *MockClient_DeleteMessage_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 int
if args[1] != nil {
arg1 = args[1].(int)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockClient_DeleteMessage_Call) Return(err error) *MockClient_DeleteMessage_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockClient_DeleteMessage_Call) RunAndReturn(run func(ctx context.Context, messageID int) error) *MockClient_DeleteMessage_Call {
_c.Call.Return(run)
return _c
}
// GetMessage provides a mock function for the type MockClient
func (_mock *MockClient) GetMessage(ctx context.Context, lastMessageID int, maxCapacity int) (*scaleset.RunnerScaleSetMessage, error) {
ret := _mock.Called(ctx, lastMessageID, maxCapacity)
if len(ret) == 0 {
panic("no return value specified for GetMessage")
}
var r0 *scaleset.RunnerScaleSetMessage
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) (*scaleset.RunnerScaleSetMessage, error)); ok {
return returnFunc(ctx, lastMessageID, maxCapacity)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, int, int) *scaleset.RunnerScaleSetMessage); ok {
r0 = returnFunc(ctx, lastMessageID, maxCapacity)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*scaleset.RunnerScaleSetMessage)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, int, int) error); ok {
r1 = returnFunc(ctx, lastMessageID, maxCapacity)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_GetMessage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetMessage'
type MockClient_GetMessage_Call struct {
*mock.Call
}
// GetMessage is a helper method to define mock.On call
// - ctx context.Context
// - lastMessageID int
// - maxCapacity int
func (_e *MockClient_Expecter) GetMessage(ctx interface{}, lastMessageID interface{}, maxCapacity interface{}) *MockClient_GetMessage_Call {
return &MockClient_GetMessage_Call{Call: _e.mock.On("GetMessage", ctx, lastMessageID, maxCapacity)}
}
func (_c *MockClient_GetMessage_Call) Run(run func(ctx context.Context, lastMessageID int, maxCapacity int)) *MockClient_GetMessage_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 int
if args[1] != nil {
arg1 = args[1].(int)
}
var arg2 int
if args[2] != nil {
arg2 = args[2].(int)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *MockClient_GetMessage_Call) Return(runnerScaleSetMessage *scaleset.RunnerScaleSetMessage, err error) *MockClient_GetMessage_Call {
_c.Call.Return(runnerScaleSetMessage, err)
return _c
}
func (_c *MockClient_GetMessage_Call) RunAndReturn(run func(ctx context.Context, lastMessageID int, maxCapacity int) (*scaleset.RunnerScaleSetMessage, error)) *MockClient_GetMessage_Call {
_c.Call.Return(run)
return _c
}
// Session provides a mock function for the type MockClient
func (_mock *MockClient) Session() scaleset.RunnerScaleSetSession {
ret := _mock.Called()
if len(ret) == 0 {
panic("no return value specified for Session")
}
var r0 scaleset.RunnerScaleSetSession
if returnFunc, ok := ret.Get(0).(func() scaleset.RunnerScaleSetSession); ok {
r0 = returnFunc()
} else {
r0 = ret.Get(0).(scaleset.RunnerScaleSetSession)
}
return r0
}
// MockClient_Session_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Session'
type MockClient_Session_Call struct {
*mock.Call
}
// Session is a helper method to define mock.On call
func (_e *MockClient_Expecter) Session() *MockClient_Session_Call {
return &MockClient_Session_Call{Call: _e.mock.On("Session")}
}
func (_c *MockClient_Session_Call) Run(run func()) *MockClient_Session_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockClient_Session_Call) Return(runnerScaleSetSession scaleset.RunnerScaleSetSession) *MockClient_Session_Call {
_c.Call.Return(runnerScaleSetSession)
return _c
}
func (_c *MockClient_Session_Call) RunAndReturn(run func() scaleset.RunnerScaleSetSession) *MockClient_Session_Call {
_c.Call.Return(run)
return _c
}
// NewMockScaler creates a new instance of MockScaler. 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 NewMockScaler(t interface {
mock.TestingT
Cleanup(func())
}) *MockScaler {
mock := &MockScaler{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockScaler is an autogenerated mock type for the Scaler type
type MockScaler struct {
mock.Mock
}
type MockScaler_Expecter struct {
mock *mock.Mock
}
func (_m *MockScaler) EXPECT() *MockScaler_Expecter {
return &MockScaler_Expecter{mock: &_m.Mock}
}
// HandleDesiredRunnerCount provides a mock function for the type MockScaler
func (_mock *MockScaler) HandleDesiredRunnerCount(ctx context.Context, count int) (int, error) {
ret := _mock.Called(ctx, count)
if len(ret) == 0 {
panic("no return value specified for HandleDesiredRunnerCount")
}
var r0 int
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, int) (int, error)); ok {
return returnFunc(ctx, count)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, int) int); ok {
r0 = returnFunc(ctx, count)
} else {
r0 = ret.Get(0).(int)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = returnFunc(ctx, count)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockScaler_HandleDesiredRunnerCount_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleDesiredRunnerCount'
type MockScaler_HandleDesiredRunnerCount_Call struct {
*mock.Call
}
// HandleDesiredRunnerCount is a helper method to define mock.On call
// - ctx context.Context
// - count int
func (_e *MockScaler_Expecter) HandleDesiredRunnerCount(ctx interface{}, count interface{}) *MockScaler_HandleDesiredRunnerCount_Call {
return &MockScaler_HandleDesiredRunnerCount_Call{Call: _e.mock.On("HandleDesiredRunnerCount", ctx, count)}
}
func (_c *MockScaler_HandleDesiredRunnerCount_Call) Run(run func(ctx context.Context, count int)) *MockScaler_HandleDesiredRunnerCount_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 int
if args[1] != nil {
arg1 = args[1].(int)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockScaler_HandleDesiredRunnerCount_Call) Return(n int, err error) *MockScaler_HandleDesiredRunnerCount_Call {
_c.Call.Return(n, err)
return _c
}
func (_c *MockScaler_HandleDesiredRunnerCount_Call) RunAndReturn(run func(ctx context.Context, count int) (int, error)) *MockScaler_HandleDesiredRunnerCount_Call {
_c.Call.Return(run)
return _c
}
// HandleJobCompleted provides a mock function for the type MockScaler
func (_mock *MockScaler) HandleJobCompleted(ctx context.Context, jobInfo *scaleset.JobCompleted) error {
ret := _mock.Called(ctx, jobInfo)
if len(ret) == 0 {
panic("no return value specified for HandleJobCompleted")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *scaleset.JobCompleted) error); ok {
r0 = returnFunc(ctx, jobInfo)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockScaler_HandleJobCompleted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleJobCompleted'
type MockScaler_HandleJobCompleted_Call struct {
*mock.Call
}
// HandleJobCompleted is a helper method to define mock.On call
// - ctx context.Context
// - jobInfo *scaleset.JobCompleted
func (_e *MockScaler_Expecter) HandleJobCompleted(ctx interface{}, jobInfo interface{}) *MockScaler_HandleJobCompleted_Call {
return &MockScaler_HandleJobCompleted_Call{Call: _e.mock.On("HandleJobCompleted", ctx, jobInfo)}
}
func (_c *MockScaler_HandleJobCompleted_Call) Run(run func(ctx context.Context, jobInfo *scaleset.JobCompleted)) *MockScaler_HandleJobCompleted_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *scaleset.JobCompleted
if args[1] != nil {
arg1 = args[1].(*scaleset.JobCompleted)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockScaler_HandleJobCompleted_Call) Return(err error) *MockScaler_HandleJobCompleted_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockScaler_HandleJobCompleted_Call) RunAndReturn(run func(ctx context.Context, jobInfo *scaleset.JobCompleted) error) *MockScaler_HandleJobCompleted_Call {
_c.Call.Return(run)
return _c
}
// HandleJobStarted provides a mock function for the type MockScaler
func (_mock *MockScaler) HandleJobStarted(ctx context.Context, jobInfo *scaleset.JobStarted) error {
ret := _mock.Called(ctx, jobInfo)
if len(ret) == 0 {
panic("no return value specified for HandleJobStarted")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, *scaleset.JobStarted) error); ok {
r0 = returnFunc(ctx, jobInfo)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockScaler_HandleJobStarted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleJobStarted'
type MockScaler_HandleJobStarted_Call struct {
*mock.Call
}
// HandleJobStarted is a helper method to define mock.On call
// - ctx context.Context
// - jobInfo *scaleset.JobStarted
func (_e *MockScaler_Expecter) HandleJobStarted(ctx interface{}, jobInfo interface{}) *MockScaler_HandleJobStarted_Call {
return &MockScaler_HandleJobStarted_Call{Call: _e.mock.On("HandleJobStarted", ctx, jobInfo)}
}
func (_c *MockScaler_HandleJobStarted_Call) Run(run func(ctx context.Context, jobInfo *scaleset.JobStarted)) *MockScaler_HandleJobStarted_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 *scaleset.JobStarted
if args[1] != nil {
arg1 = args[1].(*scaleset.JobStarted)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockScaler_HandleJobStarted_Call) Return(err error) *MockScaler_HandleJobStarted_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockScaler_HandleJobStarted_Call) RunAndReturn(run func(ctx context.Context, jobInfo *scaleset.JobStarted) error) *MockScaler_HandleJobStarted_Call {
_c.Call.Return(run)
return _c
}
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -eEuo pipefail
golangci-lint run ./...
go tool deadcode -test ./...
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -eEuo pipefail
go test ./... "$@"
+263
View File
@@ -0,0 +1,263 @@
package scaleset
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"sync"
"github.com/google/uuid"
)
// MessageSessionClient is a client used to interact with a message session for a runner scale set.
// It provides methods to Get and Delete messages from the message queue associated with the session,
// handling session token expiration and refreshing as needed.
//
// It is safe for concurrent use by multiple goroutines.
// Please do not forget to call Close when done to clean up the session.
type MessageSessionClient struct {
mu sync.Mutex
// inner client is the parent of the message session, allowing session refreshing
// use this client to create (and potentially refresh the session) requests.
innerClient *Client
// commonClient uses different options than the original client
// use this client for message session requests
commonClient *commonClient
scaleSetID int
owner string
session *RunnerScaleSetSession
}
// Close deletes the message session associated with this client.
func (c *MessageSessionClient) Close(ctx context.Context) error {
c.mu.Lock()
defer c.mu.Unlock()
return c.deleteMessageSession(ctx, c.scaleSetID, c.session.SessionID)
}
func (c *MessageSessionClient) createMessageSession(ctx context.Context) error {
path := fmt.Sprintf("/%s/%d/sessions", scaleSetEndpoint, c.scaleSetID)
newSession := &RunnerScaleSetSession{
OwnerName: c.owner,
}
requestData, err := json.Marshal(newSession)
if err != nil {
return fmt.Errorf("failed to marshal new session: %w", err)
}
var createdSession RunnerScaleSetSession
if err = c.doSessionRequest(
ctx,
http.MethodPost,
path,
bytes.NewBuffer(requestData),
http.StatusOK,
&createdSession,
); err != nil {
return fmt.Errorf("failed to do the session request: %w", err)
}
c.session = &createdSession
return nil
}
// DeleteMessageSession deletes a message session for the specified runner scale set.
func (c *MessageSessionClient) 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)
}
// RefreshMessageSession refreshes a message session for the specified runner scale set.
// This should be used when a MessageQueueTokenExpiredError is encountered.
func (c *MessageSessionClient) refreshMessageSession(ctx context.Context) error {
path := fmt.Sprintf("/%s/%d/sessions/%s", scaleSetEndpoint, c.scaleSetID, c.session.SessionID.String())
refreshedSession := &RunnerScaleSetSession{}
if err := c.doSessionRequest(ctx, http.MethodPatch, path, nil, http.StatusOK, refreshedSession); err != nil {
return fmt.Errorf("failed to do the session request: %w", err)
}
c.session = refreshedSession
return nil
}
// GetMessage fetches a message from the runner scale set message queue. If there are no messages available, it returns (nil, nil).
// Unless a message is deleted after being processed (using DeleteMessage), it will be returned again in subsequent calls.
// If the current session token is expired, it refreshes the session and tries one more time.
func (c *MessageSessionClient) GetMessage(ctx context.Context, lastMessageID int, maxCapacity int) (*RunnerScaleSetMessage, error) {
c.mu.Lock()
defer c.mu.Unlock()
message, err := c.getMessage(
ctx,
lastMessageID,
maxCapacity,
)
if err == nil {
return message, nil
}
if !errors.Is(err, MessageQueueTokenExpiredError) {
return nil, fmt.Errorf("failed to get next message: %w", err)
}
if err := c.refreshMessageSession(ctx); err != nil {
return nil, fmt.Errorf("failed to refresh message session: %w", err)
}
return c.getMessage(
ctx,
lastMessageID,
maxCapacity,
)
}
func (c *MessageSessionClient) getMessage(ctx context.Context, lastMessageID int, maxCapacity int) (*RunnerScaleSetMessage, error) {
u, err := url.Parse(c.session.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.Itoa(lastMessageID))
u.RawQuery = q.Encode()
}
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", c.session.MessageQueueAccessToken))
req.Header.Set("User-Agent", c.commonClient.userAgent)
req.Header.Set(HeaderScaleSetMaxCapacity, strconv.Itoa(maxCapacity))
resp, err := c.commonClient.do(req)
if err != nil {
return nil, fmt.Errorf("failed to issue the request: %w", err)
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusAccepted:
return nil, nil
case http.StatusOK:
message, err := parseRunnerScaleSetMessageResponse(resp.Body)
if err != nil {
return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to parse message response: %w", err))
}
return message, nil
case http.StatusUnauthorized:
return nil, newRequestResponseError(req, resp, MessageQueueTokenExpiredError)
default:
return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code %s", resp.Status))
}
}
// DeleteMessage deletes a message from the runner scale set message queue.
// This should typically be done after processing the message and acts as an acknowledgment.
// If the current session token is expired, it refreshes the session and tries one more time.
func (c *MessageSessionClient) DeleteMessage(ctx context.Context, messageID int) error {
c.mu.Lock()
defer c.mu.Unlock()
err := c.deleteMessage(ctx, messageID)
if err == nil {
return nil
}
if !errors.Is(err, MessageQueueTokenExpiredError) {
return fmt.Errorf("failed to delete message: %w", err)
}
if err := c.refreshMessageSession(ctx); err != nil {
return fmt.Errorf("failed to refresh message session: %w", err)
}
return c.deleteMessage(ctx, messageID)
}
func (c *MessageSessionClient) deleteMessage(ctx context.Context, messageID int) error {
u, err := url.Parse(c.session.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", c.session.MessageQueueAccessToken))
req.Header.Set("User-Agent", c.commonClient.userAgent)
resp, err := c.commonClient.do(req)
if err != nil {
return fmt.Errorf("failed to issue the request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNoContent {
return nil
}
if resp.StatusCode != http.StatusUnauthorized {
return newRequestResponseError(req, resp, fmt.Errorf("unexpected status code %s", resp.Status))
}
return newRequestResponseError(req, resp, MessageQueueTokenExpiredError)
}
func (c *MessageSessionClient) Session() RunnerScaleSetSession {
c.mu.Lock()
defer c.mu.Unlock()
if c.session == nil {
return RunnerScaleSetSession{}
}
return *c.session
}
func (c *MessageSessionClient) doSessionRequest(ctx context.Context, method, path string, requestData io.Reader, expectedResponseStatusCode int, responseUnmarshalTarget any) error {
req, err := c.innerClient.newActionsServiceRequest(ctx, method, path, requestData)
if err != nil {
return fmt.Errorf("failed to create new actions service request: %w", err)
}
// use potentially modified client to issue a request
resp, err := c.commonClient.do(req)
if err != nil {
return fmt.Errorf("failed to issue the request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != expectedResponseStatusCode {
return newRequestResponseError(req, resp, fmt.Errorf("unexpected status code %s", resp.Status))
}
if responseUnmarshalTarget == nil {
return nil
}
if err := json.NewDecoder(resp.Body).Decode(responseUnmarshalTarget); err != nil {
return newRequestResponseError(req, resp, fmt.Errorf("failed to unmarshal response body: %w", err))
}
return nil
}
+651
View File
@@ -0,0 +1,651 @@
package scaleset
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestSessionRequestHandler(t *testing.T, session RunnerScaleSetSession) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
srv := r.Context().Value(ctxKeyServer).(*actionsServer)
session.MessageQueueURL = srv.URL
resp, err := json.Marshal(session)
require.NoError(t, err)
w.Header().Set("Content-Type", "application/json")
w.Write(resp)
}
}
func TestCreateMessageSession(t *testing.T) {
ctx := context.Background()
auth := actionsAuth{
token: "token",
}
t.Run("CreateMessageSession unmarshals correctly", func(t *testing.T) {
runnerScaleSet := RunnerScaleSet{
ID: 1,
Name: "ScaleSet",
CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
RunnerSetting: RunnerSetting{},
}
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
handleSessionRequest(w, r)
}))
want := server.testRunnerScaleSetSession()
handleSessionRequest = newTestSessionRequestHandler(t, want)
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, runnerScaleSet.ID, "my-org")
require.NoError(t, err)
session := sessionClient.Session()
require.NotEqual(t, session.SessionID, uuid.Nil)
assert.Equal(t, want, session)
})
t.Run("CreateMessageSession includes actions exception details", func(t *testing.T) {
owner := "foo"
runnerScaleSet := RunnerScaleSet{
ID: 1,
Name: "ScaleSet",
CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
RunnerSetting: RunnerSetting{},
}
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerActionsActivityID, exampleRequestID)
w.WriteHeader(http.StatusBadRequest)
resp := []byte(`{"typeName": "CSharpExceptionNameHere","message": "could not do something"}`)
w.Write(resp)
}))
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(context.Background(), runnerScaleSet.ID, owner)
assert.Nil(t, sessionClient)
require.Error(t, err)
assert.Contains(t, err.Error(), "status=\"400 Bad Request\"")
assert.Contains(t, err.Error(), "activity_id=\""+exampleRequestID+"\"")
var ex actionsExceptionError
assert.True(t, errors.As(err, &ex))
assert.Equal(t, "CSharpExceptionNameHere", ex.ExceptionName)
assert.Equal(t, "could not do something", ex.Message)
})
t.Run("CreateMessageSession call is retried the correct amount of times", func(t *testing.T) {
owner := "foo"
runnerScaleSet := RunnerScaleSet{
ID: 1,
Name: "ScaleSet",
CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
RunnerSetting: RunnerSetting{},
}
gotRetries := 0
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
gotRetries++
}))
retryMax := 3
retryWaitMax := 1 * time.Microsecond
wantRetries := retryMax + 1
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
WithRetryMax(retryMax),
WithRetryWaitMax(retryWaitMax),
)
require.NoError(t, err)
_, err = client.MessageSessionClient(
ctx,
runnerScaleSet.ID,
owner,
WithRetryMax(retryMax),
WithRetryWaitMax(retryWaitMax),
)
assert.NotNil(t, err)
assert.Equalf(t, gotRetries, wantRetries, "CreateMessageSession got unexpected retry count: got=%v, want=%v", gotRetries, wantRetries)
})
}
func TestGetMessage(t *testing.T) {
ctx := context.Background()
auth := actionsAuth{
token: "token",
}
runnerScaleSetMessage := &RunnerScaleSetMessage{
MessageID: 1,
}
t.Run("Get Runner Scale Set Message", func(t *testing.T) {
want := runnerScaleSetMessage
response := []byte(`{"messageId":1,"messageType":"RunnerScaleSetJobMessages"}`)
var handleSessionRequest http.HandlerFunc
s := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
w.Write(response)
}))
handleSessionRequest = newTestSessionRequestHandler(t, s.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
s.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
got, err := sessionClient.GetMessage(ctx, 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":"RunnerScaleSetJobMessages"}`)
var handleSessionRequest http.HandlerFunc
s := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
q := r.URL.Query()
assert.Equal(t, "1", q.Get("lastMessageId"))
w.Write(response)
}))
handleSessionRequest = newTestSessionRequestHandler(t, s.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
s.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
got, err := sessionClient.GetMessage(ctx, 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
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
WithRetryMax(retryMax),
WithRetryWaitMax(1*time.Millisecond),
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(
ctx,
1,
"my-org",
WithRetryMax(retryMax),
WithRetryWaitMax(1*time.Millisecond),
)
require.NoError(t, err)
msg, err := sessionClient.GetMessage(ctx, 0, 10)
assert.Nil(t, msg)
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) {
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// create session
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
// refresh
if strings.Contains(r.URL.Path, "/sessions/") {
// just set the same session
handleSessionRequest(w, r)
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
msg, err := sessionClient.GetMessage(ctx, 0, 10)
assert.Nil(t, msg)
assert.ErrorIs(t, err, MessageQueueTokenExpiredError, "expected error to be MessageQueueTokenExpiredError but got: %v", err)
})
t.Run("Message token refreshed", func(t *testing.T) {
want := runnerScaleSetMessage
afterRefreshResponse := []byte(`{"messageId":1,"messageType":"RunnerScaleSetJobMessages"}`)
var handleSessionRequest http.HandlerFunc
type state int
const (
createSession state = iota
firstGetMessage
refreshToken
secondGetMessage
)
currentState := createSession
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// create session
if strings.HasSuffix(r.URL.Path, "sessions") {
require.Equal(t, createSession, currentState)
handleSessionRequest(w, r)
currentState = firstGetMessage
return
}
// refresh
if strings.Contains(r.URL.Path, "/sessions/") {
// just set the same session
require.Equal(t, refreshToken, currentState)
handleSessionRequest(w, r)
currentState = secondGetMessage
return
}
if currentState == firstGetMessage {
w.WriteHeader(http.StatusUnauthorized)
currentState = refreshToken
return
}
require.Equal(t, secondGetMessage, currentState)
w.Write(afterRefreshResponse)
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
got, err := sessionClient.GetMessage(ctx, 0, 10)
require.NoError(t, err)
assert.Equal(t, want, got)
})
t.Run("Status code not found", func(t *testing.T) {
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
w.WriteHeader(http.StatusNotFound)
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
msg, err := sessionClient.GetMessage(ctx, 0, 10)
assert.Nil(t, msg)
require.Error(t, err)
assert.Contains(t, err.Error(), "status=\"404 Not Found\"")
assert.Contains(t, err.Error(), "unknown error")
})
t.Run("Error when Content-Type is text/plain", func(t *testing.T) {
plainBody := "example plain text error"
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(plainBody))
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
msg, err := sessionClient.GetMessage(ctx, 0, 10)
assert.Nil(t, msg)
assert.NotNil(t, err)
assert.Contains(t, err.Error(), "status=\"400 Bad Request\"")
assert.Contains(t, err.Error(), plainBody)
})
t.Run("Capacity error handling", func(t *testing.T) {
plainBody := "capacity error"
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
hc := r.Header.Get(HeaderScaleSetMaxCapacity)
c, err := strconv.Atoi(hc)
require.NoError(t, err)
assert.GreaterOrEqual(t, c, 0)
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(plainBody))
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
msg, err := sessionClient.GetMessage(ctx, 0, 0)
assert.Nil(t, msg)
assert.Error(t, err)
assert.Contains(t, err.Error(), "status=\"400 Bad Request\"")
assert.Contains(t, err.Error(), plainBody)
})
}
func TestDeleteMessage(t *testing.T) {
ctx := context.Background()
auth := actionsAuth{
token: "token",
}
runnerScaleSetMessage := &RunnerScaleSetMessage{
MessageID: 1,
}
t.Run("Delete existing message", func(t *testing.T) {
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
w.WriteHeader(http.StatusNoContent)
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
err = sessionClient.DeleteMessage(ctx, runnerScaleSetMessage.MessageID)
assert.Nil(t, err)
})
t.Run("Message token expired", func(t *testing.T) {
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// create session
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
// refresh
if strings.Contains(r.URL.Path, "/sessions/") {
// just set the same session
handleSessionRequest(w, r)
return
}
w.WriteHeader(http.StatusUnauthorized)
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
err = sessionClient.DeleteMessage(ctx, 0)
require.NotNil(t, err)
assert.ErrorIs(t, err, MessageQueueTokenExpiredError, "expected error to be MessageQueueTokenExpiredError but got: %v", err)
})
t.Run("message token refreshed", func(t *testing.T) {
type state int
const (
createSession state = iota
firstDeleteMessage
refreshToken
secondDeleteMessage
)
currentState := createSession
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// create session
if strings.HasSuffix(r.URL.Path, "sessions") {
require.Equal(t, createSession, currentState)
handleSessionRequest(w, r)
currentState = firstDeleteMessage
return
}
// refresh
if strings.Contains(r.URL.Path, "/sessions/") {
// just set the same session
require.Equal(t, refreshToken, currentState)
handleSessionRequest(w, r)
currentState = secondDeleteMessage
return
}
if currentState == firstDeleteMessage {
w.WriteHeader(http.StatusUnauthorized)
currentState = refreshToken
return
}
require.Equal(t, secondDeleteMessage, currentState)
w.WriteHeader(http.StatusNoContent)
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
err = sessionClient.DeleteMessage(ctx, 0)
require.NoError(t, err)
})
t.Run("Error when Content-Type is text/plain", func(t *testing.T) {
plainBody := "example plain text error"
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
w.Header().Set("Content-Type", "text/plain")
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(plainBody))
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
err = sessionClient.DeleteMessage(ctx, runnerScaleSetMessage.MessageID)
require.NotNil(t, err)
assert.Contains(t, err.Error(), "status=\"400 Bad Request\"")
assert.Contains(t, err.Error(), plainBody)
})
t.Run("Default retries on server error", func(t *testing.T) {
actualRetry := 0
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
retryMax := 1
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
WithRetryMax(retryMax),
WithRetryWaitMax(1*time.Nanosecond),
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(
ctx,
1,
"my-org",
WithRetryMax(retryMax),
WithRetryWaitMax(1*time.Nanosecond),
)
require.NoError(t, err)
err = sessionClient.DeleteMessage(ctx, 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 := (*RunnerScaleSetMessage)(nil)
rsl, err := json.Marshal(want)
require.NoError(t, err)
var handleSessionRequest http.HandlerFunc
server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "sessions") {
handleSessionRequest(w, r)
return
}
w.Write(rsl)
}))
handleSessionRequest = newTestSessionRequestHandler(t, server.testRunnerScaleSetSession())
client, err := newClient(
testSystemInfo,
server.configURLForOrg("my-org"),
auth,
)
require.NoError(t, err)
sessionClient, err := client.MessageSessionClient(ctx, 1, "my-org")
require.NoError(t, err)
err = sessionClient.DeleteMessage(ctx, runnerScaleSetMessage.MessageID+1)
require.Error(t, err)
assert.Contains(t, err.Error(), "unexpected status code")
})
}
+29
View File
@@ -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
+19
View File
@@ -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-----
+20
View File
@@ -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-----
+28
View File
@@ -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-----
+19
View File
@@ -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-----
+20
View File
@@ -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-----
+28
View File
@@ -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-----
+149
View File
@@ -0,0 +1,149 @@
package scaleset
import (
"time"
"github.com/google/uuid"
)
const DefaultRunnerGroup = "default"
type MessageType string
// message types
const (
MessageTypeJobAssigned MessageType = "JobAssigned"
MessageTypeJobStarted MessageType = "JobStarted"
MessageTypeJobCompleted MessageType = "JobCompleted"
)
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 MessageType `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 int `json:"id"`
Name string `json:"name"`
Size int `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 runnerScaleSetMessageResponse struct {
MessageID int `json:"messageId"`
MessageType string `json:"messageType"`
Body string `json:"body"`
Statistics *RunnerScaleSetStatistic `json:"statistics"`
}
type RunnerScaleSetMessage struct {
MessageID int
Statistics *RunnerScaleSetStatistic
JobAssignedMessages []*JobAssigned
JobStartedMessages []*JobStarted
JobCompletedMessages []*JobCompleted
}
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 {
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"`
}