From e4a017ce0632f9534acafd361aa8a2344b0f2eb1 Mon Sep 17 00:00:00 2001 From: Francesco Renzi Date: Tue, 3 Feb 2026 16:41:15 +0100 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20for=20open=20source=20releas?= =?UTF-8?q?e=20=F0=9F=9A=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Francesco Renzi Co-authored-by: Nikola Jokic --- .devcontainer/Dockerfile | 3 + .devcontainer/devcontainer.json | 33 + .github/CODEOWNERS | 1 + .github/workflows/e2e.yaml | 55 ++ .github/workflows/go.yaml | 68 ++ .golangci.yaml | 11 + .mockery.yml | 14 + CODE_OF_CONDUCT.md | 74 ++ CONTRIBUTING.md | 41 + LICENSE | 21 + README.md | 185 ++++ SECURITY.md | 31 + SUPPORT.md | 13 + client.go | 1044 ++++++++++++++++++++ client_test.go | 1364 +++++++++++++++++++++++++++ common_client.go | 188 ++++ common_client_test.go | 155 +++ config.go | 109 +++ config_test.go | 195 ++++ errors.go | 98 ++ errors_test.go | 253 +++++ examples/dockerscaleset/README.md | 55 ++ examples/dockerscaleset/config.go | 141 +++ examples/dockerscaleset/e2e_test.go | 322 +++++++ examples/dockerscaleset/main.go | 193 ++++ examples/dockerscaleset/scaler.go | 195 ++++ go.mod | 89 ++ go.sum | 206 ++++ internal/testserver/server.go | 156 +++ listener/listener.go | 183 ++++ listener/listener_test.go | 183 ++++ listener/mocks_test.go | 421 +++++++++ script/lint | 8 + script/test | 6 + session_client.go | 263 ++++++ session_client_test.go | 651 +++++++++++++ testdata/generate.sh | 29 + testdata/intermediate.crt | 19 + testdata/leaf.crt | 20 + testdata/leaf.key | 28 + testdata/rootCA.crt | 19 + testdata/server.crt | 20 + testdata/server.key | 28 + types.go | 149 +++ 44 files changed, 7340 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .github/CODEOWNERS create mode 100644 .github/workflows/e2e.yaml create mode 100644 .github/workflows/go.yaml create mode 100644 .golangci.yaml create mode 100644 .mockery.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 SECURITY.md create mode 100644 SUPPORT.md create mode 100644 client.go create mode 100644 client_test.go create mode 100644 common_client.go create mode 100644 common_client_test.go create mode 100644 config.go create mode 100644 config_test.go create mode 100644 errors.go create mode 100644 errors_test.go create mode 100644 examples/dockerscaleset/README.md create mode 100644 examples/dockerscaleset/config.go create mode 100644 examples/dockerscaleset/e2e_test.go create mode 100644 examples/dockerscaleset/main.go create mode 100644 examples/dockerscaleset/scaler.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/testserver/server.go create mode 100644 listener/listener.go create mode 100644 listener/listener_test.go create mode 100644 listener/mocks_test.go create mode 100755 script/lint create mode 100755 script/test create mode 100644 session_client.go create mode 100644 session_client_test.go create mode 100644 testdata/generate.sh create mode 100644 testdata/intermediate.crt create mode 100644 testdata/leaf.crt create mode 100644 testdata/leaf.key create mode 100644 testdata/rootCA.crt create mode 100644 testdata/server.crt create mode 100644 testdata/server.key create mode 100644 types.go diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..06e76ab --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,3 @@ +FROM mcr.microsoft.com/devcontainers/go:1.25-bookworm + +USER vscode diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..fbf0c09 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -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 + } + } + } + } +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..c5e04f4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @actions/actions-runtime @nikola-jokic diff --git a/.github/workflows/e2e.yaml b/.github/workflows/e2e.yaml new file mode 100644 index 0000000..e6d8521 --- /dev/null +++ b/.github/workflows/e2e.yaml @@ -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" diff --git a/.github/workflows/go.yaml b/.github/workflows/go.yaml new file mode 100644 index 0000000..d8e3a2b --- /dev/null +++ b/.github/workflows/go.yaml @@ -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 ./... diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 0000000..06f9f8d --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,11 @@ +version: "2" +run: + timeout: 5m +linters: + settings: + errcheck: + exclude-functions: + - (net/http.ResponseWriter).Write + exclusions: + presets: + - std-error-handling \ No newline at end of file diff --git a/.mockery.yml b/.mockery.yml new file mode 100644 index 0000000..4f15e58 --- /dev/null +++ b/.mockery.yml @@ -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 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6dc4b12 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -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 . 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/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..854e322 --- /dev/null +++ b/CONTRIBUTING.md @@ -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) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..28a50fa --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2f7e42e --- /dev/null +++ b/README.md @@ -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 just‑in‑time (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. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..67a9cbf --- /dev/null +++ b/SECURITY.md @@ -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) diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 0000000..d889693 --- /dev/null +++ b/SUPPORT.md @@ -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. diff --git a/client.go b/client.go new file mode 100644 index 0000000..5889d99 --- /dev/null +++ b/client.go @@ -0,0 +1,1044 @@ +// Package scaleset package provides a client to interact with GitHub Scale Set APIs. +package scaleset + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "net/http" + "net/url" + "runtime/debug" + "strings" + "sync" + "time" + + "github.com/golang-jwt/jwt/v4" + "github.com/hashicorp/go-retryablehttp" +) + +const ( + runnerEndpoint = "_apis/distributedtask/pools/0/agents" + scaleSetEndpoint = "_apis/runtime/runnerscalesets" +) + +var buildInfo clientBuildInfo + +func init() { + packageVersion, commitSHA := detectModuleVersionAndCommit() + buildInfo = clientBuildInfo{ + version: packageVersion, + commitSHA: commitSHA, + } +} + +// HeaderScaleSetMaxCapacity is used to propagate the scale set max +// capacity when polling for messages. +const HeaderScaleSetMaxCapacity = "X-ScaleSetMaxCapacity" + +// Client implements a GitHub Actions Scale Set client. +type Client struct { + mu sync.Mutex // guards every public call + + // admin session info + actionsServiceAdminToken string + actionsServiceAdminTokenExpiresAt time.Time + actionsServiceURL string + + creds actionsAuth + config gitHubConfig + + commonClient +} + +type clientBuildInfo struct { + version string + commitSHA string +} + +type debugInfo struct { + HasProxy bool `json:"has_proxy"` + HasRootCA bool `json:"has_root_ca"` + SystemInfo string `json:"system_info"` +} + +// DebugInfo returns a JSON string containing debug information about the client, +// including whether a proxy or custom root CA is configured, and the current system info. +// This method is intended for diagnostic and troubleshooting purposes. +func (c *Client) DebugInfo() string { + c.mu.Lock() + defer c.mu.Unlock() + info := debugInfo{ + HasProxy: c.proxyFunc != nil, + HasRootCA: c.rootCAs != nil, + SystemInfo: c.userAgent, + } + + b, _ := json.Marshal(info) + return string(b) +} + +// GitHubAppAuth contains the GitHub App authentication credentials. All fields are required. +type GitHubAppAuth struct { + // ClientID is the Client ID of the application (app id also works) + ClientID string + // InstallationID is the installation ID of the GitHub App + InstallationID int64 + // PrivateKey is the private key of the GitHub App in PEM format + PrivateKey string +} + +// Validate returns an error if any required field is missing. +func (a *GitHubAppAuth) Validate() error { + if a.ClientID == "" { + return fmt.Errorf("client ID is required") + } + if a.InstallationID == 0 { + return fmt.Errorf("app installation ID is required") + } + if a.PrivateKey == "" { + return fmt.Errorf("app private key is required") + } + return nil +} + +type actionsAuth struct { + // app is the GitHub app credentials + app *GitHubAppAuth + // GitHub PAT + token string +} + +// ProxyFunc defines the function signature for a proxy function. +type ProxyFunc func(req *http.Request) (*url.URL, error) + +// SystemInfo contains information about the system that uses the +// scaleset client. +// +// For example, when Actions Runner Controller uses the scaleset API, +// it will set the following: +// - System: "actions-runner-controller" +// - Version: "release-version" +// - CommitSHA: "sha-of-the-release-commit" +// - Subsystem: "listener" or "controller" +type SystemInfo struct { + // System is the name of the scale set implementation + System string `json:"system"` + // Version is the version of the client + Version string `json:"version"` + // CommitSHA is the git commit SHA of the client + CommitSHA string `json:"commit_sha"` + // ScaleSetID is the ID of the scale set + ScaleSetID int `json:"scale_set_id"` + // Subsystem is the subsystem such as listener, controller, etc. + // Each system may pick its own subsystem name. + Subsystem string `json:"subsystem"` +} + +type ClientWithGitHubAppConfig struct { + GitHubConfigURL string + GitHubAppAuth GitHubAppAuth + SystemInfo SystemInfo +} + +// NewClientWithGitHubApp creates a new Client using GitHub App credentials. +func NewClientWithGitHubApp(config ClientWithGitHubAppConfig, options ...HTTPOption) (*Client, error) { + return newClient( + config.SystemInfo, + config.GitHubConfigURL, + actionsAuth{ + app: &config.GitHubAppAuth, + }, + options..., + ) +} + +// NewClientWithPersonalAccessTokenConfig contains the configuration for creating a new Client using a personal access token. +type NewClientWithPersonalAccessTokenConfig struct { + GitHubConfigURL string + PersonalAccessToken string + SystemInfo SystemInfo +} + +// NewClientWithPersonalAccessToken creates a new Client using a personal access token. +func NewClientWithPersonalAccessToken(config NewClientWithPersonalAccessTokenConfig, options ...HTTPOption) (*Client, error) { + return newClient( + config.SystemInfo, + config.GitHubConfigURL, + actionsAuth{ + token: config.PersonalAccessToken, + }, + options..., + ) +} + +func newClient(systemInfo SystemInfo, githubConfigURL string, creds actionsAuth, options ...HTTPOption) (*Client, error) { + config, err := parseGitHubConfigFromURL(githubConfigURL) + if err != nil { + return nil, fmt.Errorf("failed to parse githubConfigURL: %w", err) + } + + httpClientOption := httpClientOption{ + retryMax: 4, + retryWaitMax: 30 * time.Second, + } + httpClientOption.defaults() + for _, option := range options { + option(&httpClientOption) + } + + commonClient := newCommonClient( + systemInfo, + httpClientOption, + ) + + ac := &Client{ + creds: creds, + config: *config, + commonClient: *commonClient, + } + + return ac, nil +} + +// SetSystemInfo updates the information about the system. +func (c *Client) SetSystemInfo(info SystemInfo) { + c.mu.Lock() + defer c.mu.Unlock() + c.setSystemInfo(info) +} + +// SystemInfo returns the current system info that the client +// has configured. +func (c *Client) SystemInfo() SystemInfo { + c.mu.Lock() + defer c.mu.Unlock() + return c.systemInfo +} + +type userAgent struct { + SystemInfo + BuildVersion string `json:"build_version"` + BuildCommitSHA string `json:"build_commit_sha"` + Kind string `json:"kind"` +} + +func (c *Client) newGitHubAPIRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + u := c.config.gitHubAPIURL(path) + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to create new GitHub API request: %w", err) + } + + req.Header.Set("User-Agent", c.userAgent) + + return req, nil +} + +func (c *Client) newActionsServiceRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + err := c.updateTokenIfNeeded(ctx) + if err != nil { + return nil, fmt.Errorf("failed to issue update token if needed: %w", err) + } + + parsedPath, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("failed to parse path %q: %w", path, err) + } + + urlString, err := url.JoinPath(c.actionsServiceURL, parsedPath.Path) + if err != nil { + return nil, fmt.Errorf("failed to join path (actions_service_url=%q, parsedPath=%q): %w", c.actionsServiceURL, parsedPath.Path, err) + } + + u, err := url.Parse(urlString) + if err != nil { + return nil, fmt.Errorf("failed to parse url string %q: %w", urlString, err) + } + + q := u.Query() + maps.Copy(q, parsedPath.Query()) + + if q.Get("api-version") == "" { + q.Set("api-version", "6.0-preview") + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(ctx, method, u.String(), body) + if err != nil { + return nil, fmt.Errorf("failed to create new request with context: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.actionsServiceAdminToken)) + req.Header.Set("User-Agent", c.userAgent) + + return req, nil +} + +// GetRunnerScaleSet fetches a runner scale set by its name within a runner group. +func (c *Client) GetRunnerScaleSet(ctx context.Context, runnerGroupID int, runnerScaleSetName string) (*RunnerScaleSet, error) { + c.mu.Lock() + defer c.mu.Unlock() + + path := fmt.Sprintf("/%s?runnerGroupId=%d&name=%s", scaleSetEndpoint, runnerGroupID, runnerScaleSetName) + req, err := c.newActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + var runnerScaleSetList runnerScaleSetsResponse + if err := json.NewDecoder(resp.Body).Decode(&runnerScaleSetList); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode runner scale set list: %w", err)) + } + + switch runnerScaleSetList.Count { + case 1: + return &runnerScaleSetList.RunnerScaleSets[0], nil + case 0: + return nil, nil + default: + return nil, newRequestResponseError(req, resp, fmt.Errorf("multiple runner scale sets found with name %q", runnerScaleSetName)) + } +} + +// GetRunnerScaleSetByID fetches a runner scale set by its ID. +func (c *Client) GetRunnerScaleSetByID(ctx context.Context, runnerScaleSetID int) (*RunnerScaleSet, error) { + c.mu.Lock() + defer c.mu.Unlock() + + path := fmt.Sprintf("/%s/%d", scaleSetEndpoint, runnerScaleSetID) + req, err := c.newActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + var runnerScaleSet *RunnerScaleSet + if err := json.NewDecoder(resp.Body).Decode(&runnerScaleSet); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode runner scale set: %w", err)) + } + return runnerScaleSet, nil +} + +// GetRunnerGroupByName fetches a runner group by its name. +func (c *Client) GetRunnerGroupByName(ctx context.Context, runnerGroup string) (*RunnerGroup, error) { + c.mu.Lock() + defer c.mu.Unlock() + + path := fmt.Sprintf("/_apis/runtime/runnergroups/?groupName=%s", runnerGroup) + req, err := c.newActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + var runnerGroupList RunnerGroupList + if err := json.NewDecoder(resp.Body).Decode(&runnerGroupList); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode runner group list: %w", err)) + } + + switch runnerGroupList.Count { + case 1: + return &runnerGroupList.RunnerGroups[0], nil + case 0: + return nil, newRequestResponseError(req, resp, fmt.Errorf("no runner group found with name %q", runnerGroup)) + default: + return nil, newRequestResponseError(req, resp, fmt.Errorf("multiple runner group found with name %q", runnerGroup)) + } +} + +// applyDefaultLabelTypes ensures that each label in the runner scale set has a Type set, +// defaulting to "System" when the field is empty. This encapsulates the legacy API detail +// so that callers do not need to manage label types explicitly. +func applyDefaultLabelTypes(runnerScaleSet *RunnerScaleSet) { + for i := range runnerScaleSet.Labels { + if runnerScaleSet.Labels[i].Type == "" { + runnerScaleSet.Labels[i].Type = "System" + } + } +} + +// CreateRunnerScaleSet creates a new runner scale set. Note that runner scale set names must be unique within a runner group. +func (c *Client) CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) { + c.mu.Lock() + defer c.mu.Unlock() + + applyDefaultLabelTypes(runnerScaleSet) + + body, err := json.Marshal(runnerScaleSet) + if err != nil { + return nil, fmt.Errorf("failed to marshal runner scale set: %w", err) + } + + req, err := c.newActionsServiceRequest(ctx, http.MethodPost, scaleSetEndpoint, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + var createdRunnerScaleSet RunnerScaleSet + if err := json.NewDecoder(resp.Body).Decode(&createdRunnerScaleSet); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode created runner scale set: %w", err)) + } + + return &createdRunnerScaleSet, nil +} + +// UpdateRunnerScaleSet updates an existing runner scale set. +func (c *Client) UpdateRunnerScaleSet(ctx context.Context, runnerScaleSetID int, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) { + c.mu.Lock() + defer c.mu.Unlock() + + applyDefaultLabelTypes(runnerScaleSet) + + path := fmt.Sprintf("%s/%d", scaleSetEndpoint, runnerScaleSetID) + + body, err := json.Marshal(runnerScaleSet) + if err != nil { + return nil, fmt.Errorf("failed to marshal runner scale set: %w", err) + } + + req, err := c.newActionsServiceRequest(ctx, http.MethodPatch, path, bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + var updatedRunnerScaleSet RunnerScaleSet + if err := json.NewDecoder(resp.Body).Decode(&updatedRunnerScaleSet); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode updated runner scale set: %w", err)) + } + return &updatedRunnerScaleSet, nil +} + +// DeleteRunnerScaleSet deletes a runner scale set by its ID. +func (c *Client) DeleteRunnerScaleSet(ctx context.Context, runnerScaleSetID int) error { + c.mu.Lock() + defer c.mu.Unlock() + + path := fmt.Sprintf("/%s/%d", scaleSetEndpoint, runnerScaleSetID) + req, err := c.newActionsServiceRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + return nil +} + +func parseRunnerScaleSetMessageResponse(respBody io.Reader) (*RunnerScaleSetMessage, error) { + var messageResponse runnerScaleSetMessageResponse + if err := json.NewDecoder(respBody).Decode(&messageResponse); err != nil { + return nil, fmt.Errorf("failed to decode runner scale set message response: %w", err) + } + + if messageResponse.MessageType != "RunnerScaleSetJobMessages" { + return nil, fmt.Errorf("unsupported message type: %s", messageResponse.MessageType) + } + + message := &RunnerScaleSetMessage{ + MessageID: messageResponse.MessageID, + Statistics: messageResponse.Statistics, + } + + var batchedMessages []json.RawMessage + if len(messageResponse.Body) > 0 { + if err := json.Unmarshal([]byte(messageResponse.Body), &batchedMessages); err != nil { + return nil, fmt.Errorf("failed to unmarshal batched messages: %w", err) + } + } + + for _, msg := range batchedMessages { + var messageType JobMessageType + if err := json.Unmarshal(msg, &messageType); err != nil { + return nil, fmt.Errorf("failed to decode job message type: %w", err) + } + + switch messageType.MessageType { + case MessageTypeJobAssigned: + var jobAssigned JobAssigned + if err := json.Unmarshal(msg, &jobAssigned); err != nil { + return nil, fmt.Errorf("failed to decode job assigned: %w", err) + } + + message.JobAssignedMessages = append(message.JobAssignedMessages, &jobAssigned) + + case MessageTypeJobStarted: + var jobStarted JobStarted + if err := json.Unmarshal(msg, &jobStarted); err != nil { + return nil, fmt.Errorf("could not decode job started message. %w", err) + } + + message.JobStartedMessages = append(message.JobStartedMessages, &jobStarted) + + case MessageTypeJobCompleted: + var jobCompleted JobCompleted + if err := json.Unmarshal(msg, &jobCompleted); err != nil { + return nil, fmt.Errorf("failed to decode job completed: %w", err) + } + + message.JobCompletedMessages = append(message.JobCompletedMessages, &jobCompleted) + + default: + } + } + + return message, nil +} + +// MessageSessionClient creates a new MessageSessionClient for the specified runner scale set ID and owner. +// +// It exposes client options that could be overwritten, providing ability to specify different retry policies or TLS settings, proxy, etc. +func (c *Client) MessageSessionClient(ctx context.Context, runnerScaleSetID int, owner string, options ...HTTPOption) (*MessageSessionClient, error) { + c.mu.Lock() + defer c.mu.Unlock() + + // Copy original options + httpClientOption := c.httpClientOption + // Apply overwrites + for _, option := range options { + option(&httpClientOption) + } + // Instantiate a new common client + commonClient := newCommonClient( + c.systemInfo, + httpClientOption, + ) + + client := &MessageSessionClient{ + innerClient: c, + commonClient: commonClient, + owner: owner, + scaleSetID: runnerScaleSetID, + session: nil, + } + + if err := client.createMessageSession(ctx); err != nil { + return nil, fmt.Errorf("failed to create message session: %w", err) + } + + return client, nil +} + +// GenerateJitRunnerConfig generates a JIT runner configuration for the specified runner scale set. This returns an encoded +// configuration that can be used to directly start a new runner. +func (c *Client) GenerateJitRunnerConfig(ctx context.Context, jitRunnerSetting *RunnerScaleSetJitRunnerSetting, scaleSetID int) (*RunnerScaleSetJitRunnerConfig, error) { + c.mu.Lock() + defer c.mu.Unlock() + + path := fmt.Sprintf("/%s/%d/generatejitconfig", scaleSetEndpoint, scaleSetID) + + body, err := json.Marshal(jitRunnerSetting) + if err != nil { + return nil, fmt.Errorf("failed to marshal runner settings: %w", err) + } + + req, err := c.newActionsServiceRequest(ctx, http.MethodPost, path, bytes.NewBuffer(body)) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + var runnerJitConfig *RunnerScaleSetJitRunnerConfig + if err := json.NewDecoder(resp.Body).Decode(&runnerJitConfig); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode runner JIT config: %w", err)) + } + + return runnerJitConfig, nil +} + +// GetRunner fetches a runner by its ID. This can be used to check if a runner exists. +func (c *Client) GetRunner(ctx context.Context, runnerID int) (*RunnerReference, error) { + c.mu.Lock() + defer c.mu.Unlock() + + path := fmt.Sprintf("/%s/%d", runnerEndpoint, runnerID) + + req, err := c.newActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + var runnerReference *RunnerReference + if err := json.NewDecoder(resp.Body).Decode(&runnerReference); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode runner reference: %w", err)) + } + + return runnerReference, nil +} + +// GetRunnerByName fetches a runner by its name. This can be used to check if a runner exists. +func (c *Client) GetRunnerByName(ctx context.Context, runnerName string) (*RunnerReference, error) { + c.mu.Lock() + defer c.mu.Unlock() + + path := fmt.Sprintf("/%s?agentName=%s", runnerEndpoint, runnerName) + + req, err := c.newActionsServiceRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + var runnerList *RunnerReferenceList + if err := json.NewDecoder(resp.Body).Decode(&runnerList); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode runner reference list: %w", err)) + } + + switch runnerList.Count { + case 1: + return &runnerList.RunnerReferences[0], nil + case 0: + return nil, nil + default: + return nil, fmt.Errorf("multiple runners found with name %q", runnerName) + } +} + +// RemoveRunner removes a runner by its ID. +func (c *Client) RemoveRunner(ctx context.Context, runnerID int64) error { + c.mu.Lock() + defer c.mu.Unlock() + + path := fmt.Sprintf("/%s/%d", runnerEndpoint, runnerID) + + req, err := c.newActionsServiceRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return fmt.Errorf("failed to create new actions service request: %w", err) + } + + resp, err := c.do(req) + if err != nil { + return fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + return newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + return nil +} + +type registrationToken struct { + Token *string `json:"token,omitempty"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` +} + +func (c *Client) getRunnerRegistrationToken(ctx context.Context) (*registrationToken, error) { + path, err := createRegistrationTokenPath(&c.config) + if err != nil { + return nil, fmt.Errorf("failed to create registration token path: %w", err) + } + + var buf bytes.Buffer + req, err := c.newGitHubAPIRequest(ctx, http.MethodPost, path, &buf) + if err != nil { + return nil, fmt.Errorf("failed to create new GitHub API request: %w", err) + } + + bearerToken := "" + + if c.creds.token != "" { + bearerToken = fmt.Sprintf("Bearer %v", c.creds.token) + } else { + accessToken, err := c.fetchAccessToken(ctx, c.creds.app) + if err != nil { + return nil, fmt.Errorf("failed to fetch access token: %w", err) + } + + bearerToken = fmt.Sprintf("Bearer %v", accessToken.Token) + } + + req.Header.Set("Content-Type", "application/vnd.github.v3+json") + req.Header.Set("Authorization", bearerToken) + + c.logger.Info("getting runner registration token", "registrationTokenURL", req.URL.String()) + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to get runner registration token (%v)", resp.Status)) + } + + var registrationToken *registrationToken + if err := json.NewDecoder(resp.Body).Decode(®istrationToken); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode runner registration token: %w", err)) + } + + return registrationToken, nil +} + +// Format: https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app +type accessToken struct { + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` +} + +func (c *Client) fetchAccessToken(ctx context.Context, creds *GitHubAppAuth) (*accessToken, error) { + accessTokenJWT, err := createJWTForGitHubApp(creds) + if err != nil { + return nil, fmt.Errorf("failed to create JWT for GitHub app: %w", err) + } + + path := fmt.Sprintf("/app/installations/%v/access_tokens", creds.InstallationID) + req, err := c.newGitHubAPIRequest(ctx, http.MethodPost, path, nil) + if err != nil { + return nil, fmt.Errorf("failed to create new GitHub API request: %w", err) + } + + req.Header.Set("Content-Type", "application/vnd.github+json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", accessTokenJWT)) + + c.logger.Info("getting access token for GitHub App auth", "accessTokenURL", req.URL.String()) + + resp, err := c.do(req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to get access token for GitHub App auth (%v)", resp.Status)) + } + + // Format: https://docs.github.com/en/rest/apps/apps#create-an-installation-access-token-for-an-app + var accessToken accessToken + if err := json.NewDecoder(resp.Body).Decode(&accessToken); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode access token for GitHub App auth: %w", err)) + } + return &accessToken, nil +} + +type actionsServiceAdminConnection struct { + ActionsServiceURL *string `json:"url,omitempty"` + AdminToken *string `json:"token,omitempty"` +} + +func (c *Client) getActionsServiceAdminConnection(ctx context.Context, rt *registrationToken) (*actionsServiceAdminConnection, error) { + path := "/actions/runner-registration" + + body := struct { + URL string `json:"url"` + RunnerEvent string `json:"runner_event"` + }{ + URL: c.config.configURL.String(), + RunnerEvent: "register", + } + + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + + if err := enc.Encode(body); err != nil { + return nil, fmt.Errorf("failed to encode body: %w", err) + } + + req, err := c.newGitHubAPIRequest(ctx, http.MethodPost, path, &buf) + if err != nil { + return nil, fmt.Errorf("failed to create new GitHub API request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("RemoteAuth %s", *rt.Token)) + + c.logger.Info("getting Actions tenant URL and JWT", "registrationURL", req.URL.String()) + + adminConnection, err := c.getActionsServiceAdminConnectionRequest(req) + if err != nil { + return nil, fmt.Errorf("failed to get actions service admin connection: %w", err) + } + + return adminConnection, nil +} + +func (c *Client) getActionsServiceAdminConnectionRequest(req *http.Request) (*actionsServiceAdminConnection, error) { + retryableClient, err := c.newRetryableHTTPClient() + if err != nil { + return nil, fmt.Errorf("failed to create retryable HTTP client: %w", err) + } + + retryableClient.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) { + if resp != nil && (resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden) { + // Retry on 401 Unauthorized and 403 Forbidden + return true, nil + } + + return retryablehttp.DefaultRetryPolicy(ctx, resp, err) + } + // Adding custom error handler to also return response in case of error + retryableClient.ErrorHandler = func(resp *http.Response, err error, numTries int) (*http.Response, error) { + return resp, err + } + httpClient := retryableClient.StandardClient() + + resp, err := sendRequest(httpClient, req) + if err != nil { + return nil, fmt.Errorf("failed to issue the request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode > 299 { + return nil, newRequestResponseError(req, resp, fmt.Errorf("unexpected status code: %d", resp.StatusCode)) + } + + var actionsServiceAdminConnection actionsServiceAdminConnection + if err := json.NewDecoder(resp.Body).Decode(&actionsServiceAdminConnection); err != nil { + return nil, newRequestResponseError(req, resp, fmt.Errorf("failed to decode actions service admin connection: %w", err)) + } + if actionsServiceAdminConnection.ActionsServiceURL == nil || *actionsServiceAdminConnection.ActionsServiceURL == "" { + return nil, fmt.Errorf("actions service admin connection missing url") + } + if actionsServiceAdminConnection.AdminToken == nil || *actionsServiceAdminConnection.AdminToken == "" { + return nil, fmt.Errorf("actions service admin connection missing token") + } + + return &actionsServiceAdminConnection, nil +} + +func createRegistrationTokenPath(config *gitHubConfig) (string, error) { + switch config.scope { + case gitHubScopeOrganization: + path := fmt.Sprintf("/orgs/%s/actions/runners/registration-token", config.organization) + return path, nil + + case gitHubScopeEnterprise: + path := fmt.Sprintf("/enterprises/%s/actions/runners/registration-token", config.enterprise) + return path, nil + + case gitHubScopeRepository: + path := fmt.Sprintf("/repos/%s/%s/actions/runners/registration-token", config.organization, config.repository) + return path, nil + + default: + return "", fmt.Errorf("unknown scope for config url: %s", config.configURL) + } +} + +func createJWTForGitHubApp(appAuth *GitHubAppAuth) (string, error) { + // Encode as JWT + // See https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#authenticating-as-a-github-app + + // Going back in time a bit helps with clock skew. + issuedAt := time.Now().Add(-60 * time.Second) + // Max expiration date is 10 minutes. + expiresAt := issuedAt.Add(9 * time.Minute) + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(issuedAt), + ExpiresAt: jwt.NewNumericDate(expiresAt), + Issuer: appAuth.ClientID, + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(appAuth.PrivateKey)) + if err != nil { + return "", fmt.Errorf("failed to parse RSA private key from PEM: %w", err) + } + + return token.SignedString(privateKey) +} + +// Returns slice of body without utf-8 byte order mark. +// If BOM does not exist body is returned unchanged. +func trimByteOrderMark(body []byte) []byte { + return bytes.TrimPrefix(body, []byte("\xef\xbb\xbf")) +} + +func actionsServiceAdminTokenExpiresAt(jwtToken string) (time.Time, error) { + type JwtClaims struct { + jwt.RegisteredClaims + } + token, _, err := jwt.NewParser().ParseUnverified(jwtToken, &JwtClaims{}) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse jwt token: %w", err) + } + + if claims, ok := token.Claims.(*JwtClaims); ok { + return claims.ExpiresAt.Time, nil + } + + return time.Time{}, fmt.Errorf("failed to parse token claims to get expire at") +} + +func (c *Client) updateTokenIfNeeded(ctx context.Context) error { + aboutToExpire := time.Now().Add(60 * time.Second).After(c.actionsServiceAdminTokenExpiresAt) + if !aboutToExpire && !c.actionsServiceAdminTokenExpiresAt.IsZero() { + return nil + } + + configURL := "" + if c.config.configURL != nil { + configURL = c.config.configURL.String() + } + if c.logger != nil { + c.logger.Info("refreshing token", "githubConfigUrl", configURL) + } + rt, err := c.getRunnerRegistrationToken(ctx) + if err != nil { + return fmt.Errorf("failed to get runner registration token on refresh: %w", err) + } + + adminConnInfo, err := c.getActionsServiceAdminConnection(ctx, rt) + if err != nil { + return fmt.Errorf("failed to get actions service admin connection on refresh: %w", err) + } + if adminConnInfo == nil || adminConnInfo.ActionsServiceURL == nil || adminConnInfo.AdminToken == nil { + return fmt.Errorf("failed to get actions service admin connection on refresh: missing url or token") + } + + c.actionsServiceURL = *adminConnInfo.ActionsServiceURL + c.actionsServiceAdminToken = *adminConnInfo.AdminToken + c.actionsServiceAdminTokenExpiresAt, err = actionsServiceAdminTokenExpiresAt(*adminConnInfo.AdminToken) + if err != nil { + return fmt.Errorf("failed to get admin token expire at on refresh: %w", err) + } + + return nil +} + +func detectModuleVersionAndCommit() (version string, commit string) { + const modulePath = "github.com/actions/scaleset" + + bi, ok := debug.ReadBuildInfo() + if !ok { + return "unknown", "unknown" + } + + // If we are the main module (built from source in this repo), use vcs settings. + if bi.Main.Path == modulePath { + version = bi.Main.Version + commit = "unknown" + for _, s := range bi.Settings { + switch s.Key { + case "vcs.revision": + commit = s.Value + case "vcs.modified": + // Optionally append a marker if the tree was dirty. + if s.Value == "true" && commit != "unknown" { + commit = commit + "-dirty" + } + } + } + if version == "" || version == "(devel)" { + version = "devel" + } + if commit == "" { + commit = "unknown" + } + return version, commit + } + + // Otherwise search dependency list for our module. + for _, dep := range bi.Deps { + if dep.Path == modulePath { + version = dep.Version + commit = extractCommitFromVersion(version) + return version, commit + } + } + + return "unknown", "unknown" +} + +// new: parse commit from a pseudo-version (e.g. v0.0.0-20251031142550-8104f571eba7) +func extractCommitFromVersion(v string) string { + // Semantic versions without pseudo part can't yield commit; return v directly. + // Pseudo format: -- + parts := strings.Split(v, "-") + if len(parts) < 3 { + return v + } + commit := parts[len(parts)-1] + if len(commit) >= 7 { + return commit + } + return v +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..69f24b0 --- /dev/null +++ b/client_test.go @@ -0,0 +1,1364 @@ +package scaleset + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/actions/scaleset/internal/testserver" + "github.com/golang-jwt/jwt/v4" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const exampleRequestID = "5ddf2050-dae0-013c-9159-04421ad31b68" + +var testSystemInfo = SystemInfo{ + System: "test", + Subsystem: "subtest", + Version: "test-version", + CommitSHA: "test-sha", + ScaleSetID: 1, +} + +func TestNewGitHubAPIRequest(t *testing.T) { + ctx := context.Background() + + t.Run("uses the right host/path prefix", func(t *testing.T) { + scenarios := []struct { + configURL string + path string + expected string + }{ + { + configURL: "https://github.com/org/repo", + path: "/app/installations/123/access_tokens", + expected: "https://api.github.com/app/installations/123/access_tokens", + }, + { + configURL: "https://www.github.com/org/repo", + path: "/app/installations/123/access_tokens", + expected: "https://api.github.com/app/installations/123/access_tokens", + }, + { + configURL: "http://github.localhost/org/repo", + path: "/app/installations/123/access_tokens", + expected: "http://api.github.localhost/app/installations/123/access_tokens", + }, + { + configURL: "https://my-instance.com/org/repo", + path: "/app/installations/123/access_tokens", + expected: "https://my-instance.com/api/v3/app/installations/123/access_tokens", + }, + { + configURL: "http://localhost/org/repo", + path: "/app/installations/123/access_tokens", + expected: "http://localhost/api/v3/app/installations/123/access_tokens", + }, + } + + for _, scenario := range scenarios { + client, err := newClient( + testSystemInfo, + scenario.configURL, + actionsAuth{token: "token"}, + ) + require.NoError(t, err) + + req, err := client.newGitHubAPIRequest(ctx, http.MethodGet, scenario.path, nil) + require.NoError(t, err) + assert.Equal(t, scenario.expected, req.URL.String()) + } + }) + + t.Run("sets the body we pass", func(t *testing.T) { + client, err := newClient( + testSystemInfo, + "http://localhost/my-org", + actionsAuth{token: "token"}, + ) + require.NoError(t, err) + + req, err := client.newGitHubAPIRequest( + ctx, + http.MethodGet, + "/app/installations/123/access_tokens", + strings.NewReader("the-body"), + ) + require.NoError(t, err) + + b, err := io.ReadAll(req.Body) + require.NoError(t, err) + assert.Equal(t, "the-body", string(b)) + }) +} + +func TestNewActionsServiceRequest(t *testing.T) { + ctx := context.Background() + defaultCreds := actionsAuth{token: "token"} + + t.Run("manages authentication", func(t *testing.T) { + t.Run("client is brand new", func(t *testing.T) { + token := defaultActionsToken(t) + server := testserver.New(t, nil, testserver.WithActionsToken(token)) + + client, err := newClient( + testSystemInfo, + server.ConfigURLForOrg("my-org"), + defaultCreds, + ) + require.NoError(t, err) + + req, err := client.newActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.NoError(t, err) + + assert.Equal(t, "Bearer "+token, req.Header.Get("Authorization")) + }) + + t.Run("admin token is about to expire", func(t *testing.T) { + newToken := defaultActionsToken(t) + server := testserver.New(t, nil, testserver.WithActionsToken(newToken)) + + client, err := newClient( + testSystemInfo, + server.ConfigURLForOrg("my-org"), + defaultCreds, + ) + require.NoError(t, err) + client.actionsServiceAdminToken = "expiring-token" + client.actionsServiceAdminTokenExpiresAt = time.Now().Add(59 * time.Second) + + req, err := client.newActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.NoError(t, err) + + assert.Equal(t, "Bearer "+newToken, req.Header.Get("Authorization")) + }) + + t.Run("admin token refresh failure", func(t *testing.T) { + newToken := defaultActionsToken(t) + errMessage := `{"message":"test"}` + unauthorizedHandler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(errMessage)) + } + server := testserver.New( + t, + nil, + testserver.WithActionsToken("random-token"), + testserver.WithActionsToken(newToken), + testserver.WithActionsRegistrationTokenHandler(unauthorizedHandler), + ) + client, err := newClient( + testSystemInfo, + server.ConfigURLForOrg("my-org"), + defaultCreds, + WithRetryWaitMax(1*time.Millisecond), + ) + require.NoError(t, err) + expiringToken := "expiring-token" + expiresAt := time.Now().Add(59 * time.Second) + client.actionsServiceAdminToken = expiringToken + client.actionsServiceAdminTokenExpiresAt = expiresAt + _, err = client.newActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "test") + assert.Equal(t, client.actionsServiceAdminToken, expiringToken) + assert.Equal(t, client.actionsServiceAdminTokenExpiresAt, expiresAt) + }) + + t.Run("admin token refresh retry", func(t *testing.T) { + newToken := defaultActionsToken(t) + errMessage := `{"message":"test"}` + + srv := "http://github.com/my-org" + resp := &actionsServiceAdminConnection{ + AdminToken: &newToken, + ActionsServiceURL: &srv, + } + failures := 0 + unauthorizedHandler := func(w http.ResponseWriter, r *http.Request) { + if failures < 4 { + failures++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(errMessage)) + return + } + + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(resp) + } + server := testserver.New( + t, + nil, + testserver.WithActionsToken("random-token"), + testserver.WithActionsToken(newToken), + testserver.WithActionsRegistrationTokenHandler(unauthorizedHandler), + ) + client, err := newClient( + testSystemInfo, + server.ConfigURLForOrg("my-org"), + defaultCreds, + WithRetryWaitMax(1*time.Millisecond), + ) + require.NoError(t, err) + expiringToken := "expiring-token" + expiresAt := time.Now().Add(59 * time.Second) + client.actionsServiceAdminToken = expiringToken + client.actionsServiceAdminTokenExpiresAt = expiresAt + + _, err = client.newActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.NoError(t, err) + assert.Equal(t, client.actionsServiceAdminToken, newToken) + assert.Equal(t, client.actionsServiceURL, srv) + assert.NotEqual(t, client.actionsServiceAdminTokenExpiresAt, expiresAt) + }) + + t.Run("token is currently valid", func(t *testing.T) { + tokenThatShouldNotBeFetched := defaultActionsToken(t) + server := testserver.New(t, nil, testserver.WithActionsToken(tokenThatShouldNotBeFetched)) + + client, err := newClient( + testSystemInfo, + server.ConfigURLForOrg("my-org"), + defaultCreds, + ) + require.NoError(t, err) + client.actionsServiceAdminToken = "healthy-token" + client.actionsServiceAdminTokenExpiresAt = time.Now().Add(1 * time.Hour) + + req, err := client.newActionsServiceRequest(ctx, http.MethodGet, "my-path", nil) + require.NoError(t, err) + + assert.Equal(t, "Bearer healthy-token", req.Header.Get("Authorization")) + }) + }) + + t.Run("builds the right URL including api version", func(t *testing.T) { + server := testserver.New(t, nil) + + client, err := newClient( + testSystemInfo, + server.ConfigURLForOrg("my-org"), + defaultCreds, + ) + require.NoError(t, err) + + req, err := client.newActionsServiceRequest(ctx, http.MethodGet, "/my/path?name=banana", nil) + require.NoError(t, err) + + serverURL, err := url.Parse(server.URL) + require.NoError(t, err) + + result := req.URL + assert.Equal(t, serverURL.Host, result.Host) + assert.Equal(t, "/tenant/123/my/path", result.Path) + assert.Equal(t, "banana", result.Query().Get("name")) + assert.Equal(t, "6.0-preview", result.Query().Get("api-version")) + }) + + t.Run("populates header", func(t *testing.T) { + server := testserver.New(t, nil) + + client, err := newClient( + testSystemInfo, + server.ConfigURLForOrg("my-org"), + defaultCreds, + ) + require.NoError(t, err) + + client.SetSystemInfo(testSystemInfo) + + req, err := client.newActionsServiceRequest(ctx, http.MethodGet, "/my/path", nil) + require.NoError(t, err) + + assert.Equal(t, client.userAgent, req.Header.Get("User-Agent")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + }) +} + +func TestGetRunner(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + t.Run("Get Runner", func(t *testing.T) { + runnerID := 1 + want := &RunnerReference{ + ID: runnerID, + Name: "self-hosted-ubuntu", + } + response := []byte(`{"id": 1, "name": "self-hosted-ubuntu"}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GetRunner(ctx, runnerID) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + runnerID := 1 + retryWaitMax := 1 * time.Millisecond + retryMax := 1 + + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + WithRetryMax(retryMax), + WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + _, err = client.GetRunner(ctx, runnerID) + require.Error(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} + +func TestGetRunnerByName(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + t.Run("Get Runner by Name", func(t *testing.T) { + runnerID := 1 + runnerName := "self-hosted-ubuntu" + want := &RunnerReference{ + ID: runnerID, + Name: runnerName, + } + response := []byte(`{"count": 1, "value": [{"id": 1, "name": "self-hosted-ubuntu"}]}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GetRunnerByName(ctx, runnerName) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Get Runner by name with not exist runner", func(t *testing.T) { + runnerName := "self-hosted-ubuntu" + response := []byte(`{"count": 0, "value": []}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GetRunnerByName(ctx, runnerName) + require.NoError(t, err) + assert.Nil(t, got) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + runnerName := "self-hosted-ubuntu" + + retryWaitMax := 1 * time.Millisecond + retryMax := 1 + + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + WithRetryMax(retryMax), + WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + _, err = client.GetRunnerByName(ctx, runnerName) + require.Error(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} + +func TestDeleteRunner(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + t.Run("Delete Runner", func(t *testing.T) { + var runnerID int64 = 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + err = client.RemoveRunner(ctx, runnerID) + assert.NoError(t, err) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + var runnerID int64 = 1 + + retryWaitMax := 1 * time.Millisecond + retryMax := 1 + + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + WithRetryMax(retryMax), + WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + err = client.RemoveRunner(ctx, runnerID) + require.Error(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} + +func TestGetRunnerGroupByName(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + t.Run("Get RunnerGroup by Name", func(t *testing.T) { + runnerGroupID := 1 + runnerGroupName := "test-runner-group" + want := &RunnerGroup{ + ID: runnerGroupID, + Name: runnerGroupName, + } + response := []byte(`{"count": 1, "value": [{"id": 1, "name": "test-runner-group"}]}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GetRunnerGroupByName(ctx, runnerGroupName) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Get RunnerGroup by name with not exist runner group", func(t *testing.T) { + runnerGroupName := "test-runner-group" + response := []byte(`{"count": 0, "value": []}`) + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(response) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GetRunnerGroupByName(ctx, runnerGroupName) + assert.ErrorContains(t, err, "no runner group found with name") + assert.Nil(t, got) + }) +} + +func TestGetRunnerScaleSet(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + scaleSetName := "ScaleSet" + runnerScaleSet := RunnerScaleSet{ID: 1, Name: scaleSetName} + + t.Run("Get existing scale set", func(t *testing.T) { + want := &runnerScaleSet + runnerScaleSetsResp := []byte(`{"count":1,"value":[{"id":1,"name":"ScaleSet"}]}`) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(runnerScaleSetsResp) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GetRunnerScaleSet(ctx, 1, scaleSetName) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("GetRunnerScaleSet calls correct url", func(t *testing.T) { + runnerScaleSetsResp := []byte(`{"count":1,"value":[{"id":1,"name":"ScaleSet"}]}`) + url := url.URL{} + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(runnerScaleSetsResp) + url = *r.URL + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + require.NoError(t, err) + + expectedPath := "/tenant/123/_apis/runtime/runnerscalesets" + assert.Equal(t, expectedPath, url.Path) + assert.Equal(t, scaleSetName, url.Query().Get("name")) + assert.Equal(t, "6.0-preview", url.Query().Get("api-version")) + }) + + t.Run("Status code not found", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + assert.NotNil(t, err) + }) + + t.Run("Error when Content-Type is text/plain", func(t *testing.T) { + plainBody := "example plain text error" + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(plainBody)) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + assert.NotNil(t, err) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + actualRetry := 0 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + retryMax := 1 + retryWaitMax := 1 * time.Microsecond + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + WithRetryMax(retryMax), + WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + assert.NotNil(t, err) + expectedRetry := retryMax + 1 + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) + + t.Run("RunnerScaleSet count is zero", func(t *testing.T) { + want := (*RunnerScaleSet)(nil) + runnerScaleSetsResp := []byte(`{"count":0,"value":[{"id":1,"name":"ScaleSet"}]}`) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(runnerScaleSetsResp) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GetRunnerScaleSet(ctx, 1, scaleSetName) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Multiple runner scale sets found", func(t *testing.T) { + reqID := uuid.NewString() + runnerScaleSetsResp := []byte(`{"count":2,"value":[{"id":1,"name":"ScaleSet"}]}`) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set(headerActionsActivityID, reqID) + w.Write(runnerScaleSetsResp) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSet(ctx, 1, scaleSetName) + require.NotNil(t, err) + assert.Contains(t, err.Error(), "multiple runner scale sets found") + assert.Contains(t, err.Error(), "activity_id=\""+reqID+"\"") + }) +} + +func TestGetRunnerScaleSetByID(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC) + runnerScaleSet := RunnerScaleSet{ID: 1, Name: "ScaleSet", CreatedOn: scaleSetCreationDateTime, RunnerSetting: RunnerSetting{}} + + t.Run("Get existing scale set by Id", func(t *testing.T) { + want := &runnerScaleSet + rsl, err := json.Marshal(want) + require.NoError(t, err) + sservere := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(rsl) + })) + + client, err := newClient( + testSystemInfo, + sservere.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GetRunnerScaleSetByID(ctx, runnerScaleSet.ID) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("GetRunnerScaleSetByID calls correct url", func(t *testing.T) { + rsl, err := json.Marshal(&runnerScaleSet) + require.NoError(t, err) + + url := url.URL{} + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(rsl) + url = *r.URL + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSetByID(ctx, runnerScaleSet.ID) + require.NoError(t, err) + + expectedPath := fmt.Sprintf("/tenant/123/_apis/runtime/runnerscalesets/%d", runnerScaleSet.ID) + assert.Equal(t, expectedPath, url.Path) + assert.Equal(t, "6.0-preview", url.Query().Get("api-version")) + }) + + t.Run("Status code not found", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSetByID(ctx, runnerScaleSet.ID) + assert.NotNil(t, err) + }) + + t.Run("Error when Content-Type is text/plain", func(t *testing.T) { + plainBody := "example plain text error" + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(plainBody)) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSetByID(ctx, runnerScaleSet.ID) + assert.NotNil(t, err) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + actualRetry := 0 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + retryMax := 1 + retryWaitMax := 1 * time.Microsecond + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + WithRetryMax(retryMax), + WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + _, err = client.GetRunnerScaleSetByID(ctx, runnerScaleSet.ID) + require.NotNil(t, err) + expectedRetry := retryMax + 1 + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) + + t.Run("No RunnerScaleSet found", func(t *testing.T) { + want := (*RunnerScaleSet)(nil) + rsl, err := json.Marshal(want) + require.NoError(t, err) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(rsl) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GetRunnerScaleSetByID(ctx, runnerScaleSet.ID) + require.NoError(t, err) + assert.Equal(t, want, got) + }) +} + +func TestCreateRunnerScaleSet(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC) + runnerScaleSet := RunnerScaleSet{ID: 1, Name: "ScaleSet", CreatedOn: scaleSetCreationDateTime, RunnerSetting: RunnerSetting{}} + + t.Run("Create runner scale set", func(t *testing.T) { + want := &runnerScaleSet + rsl, err := json.Marshal(want) + require.NoError(t, err) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(rsl) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.CreateRunnerScaleSet(ctx, &runnerScaleSet) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("CreateRunnerScaleSet calls correct url", func(t *testing.T) { + rsl, err := json.Marshal(&runnerScaleSet) + require.NoError(t, err) + url := url.URL{} + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(rsl) + url = *r.URL + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.CreateRunnerScaleSet(ctx, &runnerScaleSet) + require.NoError(t, err) + + expectedPath := "/tenant/123/_apis/runtime/runnerscalesets" + assert.Equal(t, expectedPath, url.Path) + assert.Equal(t, "6.0-preview", url.Query().Get("api-version")) + }) + + t.Run("Error when Content-Type is text/plain", func(t *testing.T) { + plainBody := "example plain text error" + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(plainBody)) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.CreateRunnerScaleSet(ctx, &runnerScaleSet) + 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 + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + retryMax := 1 + retryWaitMax := 1 * time.Microsecond + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + WithRetryMax(retryMax), + WithRetryWaitMax(retryWaitMax), + ) + require.NoError(t, err) + + _, err = client.CreateRunnerScaleSet(ctx, &runnerScaleSet) + require.NotNil(t, err) + expectedRetry := retryMax + 1 + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} + +func TestUpdateRunnerScaleSet(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC) + runnerScaleSet := RunnerScaleSet{ID: 1, Name: "ScaleSet", RunnerGroupID: 1, RunnerGroupName: "group", CreatedOn: scaleSetCreationDateTime, RunnerSetting: RunnerSetting{}} + + t.Run("Update runner scale set", func(t *testing.T) { + want := &runnerScaleSet + rsl, err := json.Marshal(want) + require.NoError(t, err) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(rsl) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.UpdateRunnerScaleSet(ctx, 1, &RunnerScaleSet{RunnerGroupID: 1}) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("UpdateRunnerScaleSet calls correct url", func(t *testing.T) { + rsl, err := json.Marshal(&runnerScaleSet) + require.NoError(t, err) + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + expectedPath := "/tenant/123/_apis/runtime/runnerscalesets/1" + assert.Equal(t, expectedPath, r.URL.Path) + assert.Equal(t, http.MethodPatch, r.Method) + assert.Equal(t, "6.0-preview", r.URL.Query().Get("api-version")) + + w.Write(rsl) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + _, err = client.UpdateRunnerScaleSet(ctx, 1, &runnerScaleSet) + require.NoError(t, err) + }) +} + +func TestDeleteRunnerScaleSet(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + t.Run("Delete runner scale set", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + assert.Contains(t, r.URL.String(), "/_apis/runtime/runnerscalesets/10?api-version=6.0-preview") + w.WriteHeader(http.StatusNoContent) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + err = client.DeleteRunnerScaleSet(ctx, 10) + assert.NoError(t, err) + }) + + t.Run("Delete calls with error", func(t *testing.T) { + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "DELETE", r.Method) + assert.Contains(t, r.URL.String(), "/_apis/runtime/runnerscalesets/10?api-version=6.0-preview") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(`{"message": "test error"}`)) + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + err = client.DeleteRunnerScaleSet(ctx, 10) + assert.ErrorContains(t, err, "test error") + }) +} + +func TestGenerateJitRunnerConfig(t *testing.T) { + ctx := context.Background() + auth := actionsAuth{ + token: "token", + } + + t.Run("Get JIT Config for Runner", func(t *testing.T) { + want := &RunnerScaleSetJitRunnerConfig{} + response := []byte(`{"count":1,"value":[{"id":1,"name":"scale-set-name"}]}`) + + runnerSettings := &RunnerScaleSetJitRunnerSetting{} + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Write(response) + })) + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + ) + require.NoError(t, err) + + got, err := client.GenerateJitRunnerConfig(ctx, runnerSettings, 1) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("Default retries on server error", func(t *testing.T) { + runnerSettings := &RunnerScaleSetJitRunnerSetting{} + + retryMax := 1 + actualRetry := 0 + expectedRetry := retryMax + 1 + + server := newActionsServer(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + actualRetry++ + })) + + client, err := newClient( + testSystemInfo, + server.configURLForOrg("my-org"), + auth, + WithRetryMax(1), + WithRetryWaitMax(1*time.Millisecond), + ) + require.NoError(t, err) + + _, err = client.GenerateJitRunnerConfig(ctx, runnerSettings, 1) + assert.NotNil(t, err) + assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry) + }) +} + +type serverCtxKey int + +const ctxKeyServer serverCtxKey = iota + +// newActionsServer returns a new httptest.Server that handles the +// authentication requests neeeded to create a new client. Any requests not +// made to the /actions/runners/registration-token or +// /actions/runner-registration endpoints will be handled by the provided +// handler. The returned server is started and will be automatically closed +// when the test ends. +func newActionsServer(t *testing.T, handler http.Handler, options ...actionsServerOption) *actionsServer { + s := httptest.NewServer(nil) + server := &actionsServer{ + Server: s, + } + t.Cleanup(func() { + server.Close() + }) + + for _, option := range options { + option(server) + } + + h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r = r.WithContext(context.WithValue(r.Context(), ctxKeyServer, server)) + // handle getRunnerRegistrationToken + if strings.HasSuffix(r.URL.Path, "/runners/registration-token") { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"token":"token"}`)) + return + } + + // handle getActionsServiceAdminConnection + if strings.HasSuffix(r.URL.Path, "/actions/runner-registration") { + if server.token == "" { + server.token = defaultActionsToken(t) + } + + w.Write([]byte(`{"url":"` + s.URL + `/tenant/123/","token":"` + server.token + `"}`)) + return + } + + handler.ServeHTTP(w, r) + }) + + server.Config.Handler = h + + return server +} + +type actionsServerOption func(*actionsServer) + +type actionsServer struct { + *httptest.Server + + token string +} + +func (s *actionsServer) testRunnerScaleSetSession() RunnerScaleSetSession { + session := RunnerScaleSetSession{ + SessionID: uuid.New(), + OwnerName: "foo", + RunnerScaleSet: &RunnerScaleSet{ + ID: 1, + Name: "ScaleSet", + }, + MessageQueueURL: s.URL, + MessageQueueAccessToken: s.token, + Statistics: &RunnerScaleSetStatistic{ + TotalAvailableJobs: 0, + TotalAcquiredJobs: 0, + TotalAssignedJobs: 0, + TotalRunningJobs: 0, + TotalRegisteredRunners: 0, + TotalBusyRunners: 0, + TotalIdleRunners: 0, + }, + } + return session +} + +func (s *actionsServer) configURLForOrg(org string) string { + return s.URL + "/" + org +} + +func defaultActionsToken(t *testing.T) string { + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-10 * time.Minute)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(10 * time.Minute)), + Issuer: "123", + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(samplePrivateKey)) + require.NoError(t, err) + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + return tokenString +} + +func TestServerWithSelfSignedCertificates(t *testing.T) { + ctx := context.Background() + // this handler is a very very barebones replica of actions api + // used during the creation of a a new client + var u string + h := func(w http.ResponseWriter, r *http.Request) { + // handle get registration token + if strings.HasSuffix(r.URL.Path, "/runners/registration-token") { + w.WriteHeader(http.StatusCreated) + w.Write([]byte(`{"token":"token"}`)) + return + } + + // handle getActionsServiceAdminConnection + if strings.HasSuffix(r.URL.Path, "/actions/runner-registration") { + claims := &jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(time.Now().Add(-1 * time.Minute)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Minute)), + Issuer: "123", + } + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(samplePrivateKey)) + require.NoError(t, err) + tokenString, err := token.SignedString(privateKey) + require.NoError(t, err) + w.Write([]byte(`{"url":"` + u + `","token":"` + tokenString + `"}`)) + return + } + + // default happy response for RemoveRunner + w.WriteHeader(http.StatusNoContent) + } + + certPath := filepath.Join("testdata", "server.crt") + keyPath := filepath.Join("testdata", "server.key") + + t.Run("client without ca certs", func(t *testing.T) { + server := startNewTLSTestServer(t, certPath, keyPath, http.HandlerFunc(h)) + u = server.URL + configURL := server.URL + "/my-org" + + client, err := newClient( + testSystemInfo, + configURL, + actionsAuth{ + token: "token", + }, + ) + require.NoError(t, err) + require.NotNil(t, client) + + err = client.RemoveRunner(ctx, 1) + require.NotNil(t, err) + + if runtime.GOOS == "linux" { + assert.True(t, errors.As(err, &x509.UnknownAuthorityError{})) + } + + // on macOS we only get an untyped error from the system verifying the + // certificate + if runtime.GOOS == "darwin" { + assert.True(t, strings.HasSuffix(err.Error(), "certificate is not trusted")) + } + }) + + t.Run("client with ca certs", func(t *testing.T) { + server := startNewTLSTestServer( + t, + certPath, + keyPath, + http.HandlerFunc(h), + ) + u = server.URL + configURL := server.URL + "/my-org" + + cert, err := os.ReadFile(filepath.Join("testdata", "rootCA.crt")) + require.NoError(t, err) + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(cert)) + + client, err := newClient( + testSystemInfo, + configURL, + actionsAuth{ + token: "token", + }, + WithRootCAs(pool), + ) + require.NoError(t, err) + assert.NotNil(t, client) + + err = client.RemoveRunner(ctx, 1) + assert.NoError(t, err) + }) + + t.Run("client with ca chain certs", func(t *testing.T) { + server := startNewTLSTestServer( + t, + filepath.Join("testdata", "leaf.crt"), + filepath.Join("testdata", "leaf.key"), + http.HandlerFunc(h), + ) + u = server.URL + configURL := server.URL + "/my-org" + + cert, err := os.ReadFile(filepath.Join("testdata", "intermediate.crt")) + require.NoError(t, err) + + pool := x509.NewCertPool() + require.True(t, pool.AppendCertsFromPEM(cert)) + + client, err := newClient( + testSystemInfo, + configURL, + actionsAuth{ + token: "token", + }, + WithRootCAs(pool), + WithRetryMax(0), + ) + require.NoError(t, err) + require.NotNil(t, client) + + err = client.RemoveRunner(ctx, 1) + assert.NoError(t, err) + }) + + t.Run("client skipping tls verification", func(t *testing.T) { + server := startNewTLSTestServer(t, certPath, keyPath, http.HandlerFunc(h)) + configURL := server.URL + "/my-org" + + client, err := newClient( + testSystemInfo, + configURL, + actionsAuth{ + token: "token", + }, + WithoutTLSVerify(), + ) + require.NoError(t, err) + assert.NotNil(t, client) + }) +} + +func startNewTLSTestServer(t *testing.T, certPath, keyPath string, handler http.Handler) *httptest.Server { + server := httptest.NewUnstartedServer(handler) + t.Cleanup(func() { + server.Close() + }) + + cert, err := tls.LoadX509KeyPair(certPath, keyPath) + require.NoError(t, err) + + server.TLS = &tls.Config{Certificates: []tls.Certificate{cert}} + server.StartTLS() + + return server +} + +const samplePrivateKey = `-----BEGIN PRIVATE KEY----- +MIIEugIBADANBgkqhkiG9w0BAQEFAASCBKQwggSgAgEAAoIBAQC7tgquvNIp+Ik3 +rRVZ9r0zJLsSzTHqr2dA6EUUmpRiQ25MzjMqKqu0OBwvh/pZyfjSIkKrhIridNK4 +DWnPfPWHE2K3Muh0X2sClxtqiiFmXsvbiTzhUm5a+zCcv0pJCWYnKi0HmyXpAXjJ +iN8mWliZN896verVYXWrod7EaAnuST4TiJeqZYW4bBBG81fPNc/UP4j6CKAW8nx9 +HtcX6ApvlHeCLZUTW/qhGLO0nLKoEOr3tXCPW5VjKzlm134Dl+8PN6f1wv6wMAoA +lo7Ha5+c74jhPL6gHXg7cRaHQmuJCJrtl8qbLkFAulfkBixBw/6i11xoM/MOC64l +TWmXqrxTAgMBAAECgf9zYlxfL+rdHRXCoOm7pUeSPL0dWaPFP12d/Z9LSlDAt/h6 +Pd+eqYEwhf795SAbJuzNp51Ls6LUGnzmLOdojKwfqJ51ahT1qbcBcMZNOcvtGqZ9 +xwLG993oyR49C361Lf2r8mKrdrR5/fW0B1+1s6A+eRFivqFOtsOc4V4iMeHYsCVJ +hM7yMu0UfpolDJA/CzopsoGq3UuQlibUEUxKULza06aDjg/gBH3PnP+fQ1m0ovDY +h0pX6SCq5fXVJFS+Pbpu7j2ePNm3mr0qQhrUONZq0qhGN/piCbBZe1CqWApyO7nA +B95VChhL1eYs1BKvQePh12ap83woIUcW2mJF2F0CgYEA+aERTuKWEm+zVNKS9t3V +qNhecCOpayKM9OlALIK/9W6KBS+pDsjQQteQAUAItjvLiDjd5KsrtSgjbSgr66IP +b615Pakywe5sdnVGzSv+07KMzuFob9Hj6Xv9als9Y2geVhUZB2Frqve/UCjmC56i +zuQTSele5QKCSSTFBV3423cCgYEAwIBv9ChsI+mse6vPaqSPpZ2n237anThMcP33 +aS0luYXqMWXZ0TQ/uSmCElY4G3xqNo8szzfy6u0HpldeUsEUsIcBNUV5kIIb8wKu +Zmgcc8gBIjJkyUJI4wuz9G/fegEUj3u6Cttmmj4iWLzCRscRJdfGpqwRIhOGyXb9 +2Rur5QUCgYAGWIPaH4R1H4XNiDTYNbdyvV1ZOG7cHFq89xj8iK5cjNzRWO7RQ2WX +7WbpwTj3ePmpktiBMaDA0C5mXfkP2mTOD/jfCmgR6f+z2zNbj9zAgO93at9+yDUl +AFPm2j7rQgBTa+HhACb+h6HDZebDMNsuqzmaTWZuJ+wr89VWV5c17QKBgH3jwNNQ +mCAIUidynaulQNfTOZIe7IMC7WK7g9CBmPkx7Y0uiXr6C25hCdJKFllLTP6vNWOy +uCcQqf8LhgDiilBDifO3op9xpyuOJlWMYocJVkxx3l2L/rSU07PYcbKNAFAxXuJ4 +xym51qZnkznMN5ei/CPFxVKeqHgaXDpekVStAoGAV3pSWAKDXY/42XEHixrCTqLW +kBxfaf3g7iFnl3u8+7Z/7Cb4ZqFcw0bRJseKuR9mFvBhcZxSErbMDEYrevefU9aM +APeCxEyw6hJXgbWKoG7Fw2g2HP3ytCJ4YzH0zNitHjk/1h4BG7z8cEQILCSv5mN2 +etFcaQuTHEZyRhhJ4BU= +-----END PRIVATE KEY-----` diff --git a/common_client.go b/common_client.go new file mode 100644 index 0000000..e7bf7bd --- /dev/null +++ b/common_client.go @@ -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 + } +} diff --git a/common_client_test.go b/common_client_test.go new file mode 100644 index 0000000..d06e1de --- /dev/null +++ b/common_client_test.go @@ -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) +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..fcf549d --- /dev/null +++ b/config.go @@ -0,0 +1,109 @@ +package scaleset + +import ( + "fmt" + "net/url" + "os" + "strings" +) + +var ErrInvalidGitHubConfigURL = fmt.Errorf("invalid config URL, should point to an enterprise, org, or repository") + +type gitHubScope int + +const ( + gitHubScopeUnknown gitHubScope = iota + gitHubScopeEnterprise + gitHubScopeOrganization + gitHubScopeRepository +) + +type gitHubConfig struct { + configURL *url.URL + scope gitHubScope + + enterprise string + organization string + repository string + + isHosted bool +} + +func parseGitHubConfigFromURL(in string) (*gitHubConfig, error) { + u, err := url.Parse(strings.Trim(in, "/")) + if err != nil { + return nil, 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") +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..bbdb015 --- /dev/null +++ b/config_test.go @@ -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()) + }) +} diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..dbf49a7 --- /dev/null +++ b/errors.go @@ -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) + } +} diff --git a/errors_test.go b/errors_test.go new file mode 100644 index 0000000..4d9269b --- /dev/null +++ b/errors_test.go @@ -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) + }) +} diff --git a/examples/dockerscaleset/README.md b/examples/dockerscaleset/README.md new file mode 100644 index 0000000..9c12a7b --- /dev/null +++ b/examples/dockerscaleset/README.md @@ -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.* diff --git a/examples/dockerscaleset/config.go b/examples/dockerscaleset/config.go new file mode 100644 index 0000000..f62a929 --- /dev/null +++ b/examples/dockerscaleset/config.go @@ -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}} +} diff --git a/examples/dockerscaleset/e2e_test.go b/examples/dockerscaleset/e2e_test.go new file mode 100644 index 0000000..1e8ac97 --- /dev/null +++ b/examples/dockerscaleset/e2e_test.go @@ -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 +} diff --git a/examples/dockerscaleset/main.go b/examples/dockerscaleset/main.go new file mode 100644 index 0000000..a21568d --- /dev/null +++ b/examples/dockerscaleset/main.go @@ -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) + }, +} diff --git a/examples/dockerscaleset/scaler.go b/examples/dockerscaleset/scaler.go new file mode 100644 index 0000000..545d7e0 --- /dev/null +++ b/examples/dockerscaleset/scaler.go @@ -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() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..04e3b02 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..597ac37 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/testserver/server.go b/internal/testserver/server.go new file mode 100644 index 0000000..75f88ca --- /dev/null +++ b/internal/testserver/server.go @@ -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-----` diff --git a/listener/listener.go b/listener/listener.go new file mode 100644 index 0000000..907ab12 --- /dev/null +++ b/listener/listener.go @@ -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 +} diff --git a/listener/listener_test.go b/listener/listener_test.go new file mode 100644 index 0000000..bc1045e --- /dev/null +++ b/listener/listener_test.go @@ -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) + }) +} diff --git a/listener/mocks_test.go b/listener/mocks_test.go new file mode 100644 index 0000000..d46c47d --- /dev/null +++ b/listener/mocks_test.go @@ -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 +} diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..7a0580a --- /dev/null +++ b/script/lint @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +set -eEuo pipefail + +golangci-lint run ./... + +go tool deadcode -test ./... + diff --git a/script/test b/script/test new file mode 100755 index 0000000..961b43b --- /dev/null +++ b/script/test @@ -0,0 +1,6 @@ +#!/usr/bin/env bash + +set -eEuo pipefail + +go test ./... "$@" + diff --git a/session_client.go b/session_client.go new file mode 100644 index 0000000..80c20d9 --- /dev/null +++ b/session_client.go @@ -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 +} diff --git a/session_client_test.go b/session_client_test.go new file mode 100644 index 0000000..79c0e69 --- /dev/null +++ b/session_client_test.go @@ -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") + }) +} diff --git a/testdata/generate.sh b/testdata/generate.sh new file mode 100644 index 0000000..7790be4 --- /dev/null +++ b/testdata/generate.sh @@ -0,0 +1,29 @@ +#!/bin/bash + +# Generate Root CA +openssl genrsa -out rootCA.key 2048 +openssl req -x509 -new -nodes -key rootCA.key -sha256 -days 1024 -out rootCA.crt -subj "/CN=Test Root CA" \ + -addext "basicConstraints = critical, CA:TRUE" \ + -addext "keyUsage = critical, keyCertSign, cRLSign" + +# Generate Intermediate Certificate +openssl genrsa -out intermediate.key 2048 +openssl req -new -key intermediate.key -out intermediate.csr -subj "/CN=Test Intermediate CA" +openssl x509 -req -in intermediate.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial -out intermediate.crt -days 1000 -sha256 \ + -extfile <(echo -e "basicConstraints = critical, CA:TRUE, pathlen:0\nkeyUsage = critical, keyCertSign, cRLSign") + +# Generate Leaf Certificate +openssl genrsa -out leaf.key 2048 +openssl req -new -key leaf.key -out leaf.csr -subj "/CN=localhost" \ + -addext "subjectAltName = IP:127.0.0.1" +openssl x509 -req -in leaf.csr -CA intermediate.crt -CAkey intermediate.key -CAcreateserial -out leaf.crt -days 500 -sha256 \ + -extfile <(echo -e "authorityKeyIdentifier=keyid,issuer\nbasicConstraints=CA:FALSE\nkeyUsage = digitalSignature, keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1") + +# Generate Leaf Certificate +openssl genrsa -out server.key 2048 +openssl req -new -key server.key -out server.csr -subj "/CN=localhost" \ + -addext "subjectAltName = IP:127.0.0.1" +openssl x509 -req -in server.csr -CA rootCA.crt -CAkey rootCA.key -CAcreateserial -out server.crt -days 500 -sha256 \ + -extfile <(echo -e "authorityKeyIdentifier=keyid,issuer\nbasicConstraints=CA:FALSE\nkeyUsage = digitalSignature, keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=IP:127.0.0.1") + +rm rootCA.key intermediate.key *.csr *.srl diff --git a/testdata/intermediate.crt b/testdata/intermediate.crt new file mode 100644 index 0000000..476da8e --- /dev/null +++ b/testdata/intermediate.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKjCCAhKgAwIBAgIUQr7R8yN5+2and6ucUOPF6oIbD44wDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MDIyODEyMDEzMFoXDTI3 +MTEyNTEyMDEzMFowHzEdMBsGA1UEAwwUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwggEi +MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDOGvN95wCkYO35qyJnf/RwTiDb +oEVaefKnZZny1JrO34MFjlAz8C/P5WwxNUzzbQLTPh5iTqFRU+vis6HPvV0HJEoI +wTfgBCZxcdY8fEIY96FGHLju3PzfxeJaVHyi+2cTtzU+oNp4OFF8huApjYXjaV4y +pAirPbiiP/cgtcT4L5WErQi0aGZkq+1YqY2duNFNIGPTEcXV4iN4IhuD9dpqdKFg +H0wmZDgH+VE/5ACXovU8j5cxCKOJGxTVMKVZlvxPH3w69Z85x3o5AAnyxwo8E2zo +TC1FJ1eFLsmYLZki6cGBzSkIl5QlLGHakWYh+JLu/pkfTL8t+AkY3hZJM96ZAgMB +AAGjZjBkMBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1Ud +DgQWBBSmAyntm95+KoyL3ffLBXZKSpk1VTAfBgNVHSMEGDAWgBR93+rQFjh+RUFX +df4dbNcfS2hbTzANBgkqhkiG9w0BAQsFAAOCAQEABopVFLGQf/LFH+OKVCOT8FCC +y/+o1B/U5jXVvbfwlSGScaiJGQ94FsuH59XJCGySQj77ZVTeElBtntoLXmOCFjyF +jKHCDfUpB4nzeqNMvTDzuoYyPS8DhoGfEnaCgJyKf6GU4p41502gH8mQRB7azzL7 +5jW0aFatCA6G6T1oogHZpHf0ice80C2JkFbWHSE9JxqARbTc06wCDBiSBFTGZQDO +JaBIbn6FL3zSkKcpwgJEqDRavVuoDUlJPDqtTzjf/fMQGGR2LUFkceJpsQqf1jrF +1yTtEZ8gjR2g2Vj6IszUAgbc87xR0AgyGDVckiUdhlX2Y6KCqo2cl9LfSVpqtw== +-----END CERTIFICATE----- diff --git a/testdata/leaf.crt b/testdata/leaf.crt new file mode 100644 index 0000000..1d05047 --- /dev/null +++ b/testdata/leaf.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQjCCAiqgAwIBAgIUHT3JtqsYKs7NHv1LNyS9RYC7vsAwDQYJKoZIhvcNAQEL +BQAwHzEdMBsGA1UEAwwUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwHhcNMjUwMjI4MTIw +MTMwWhcNMjYwNzEzMTIwMTMwWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0G +CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHwI/xSLgCuQrx+WsvupA8w4eMdSef +WGw523OJYPJkDYJGgSGsjVb9htba6vXYbGNohuluEAZIyT7GvmPezTokeVMkuSYT +lSV8xplFEtDlQhTzaI/cofbi7qtT91/5zS/w0JSaNosThGtZg/M4ZOiMj04m0NGK +Zz56l9Lpe/yM7fPda++D9xYEGSSdwK9CqqwF+cXN09d6IK1VINIIjT3Sdb9Sssok +GWmD7UUPLvwZ5379+HRs1K8AFXqvbkeWVYtrJwJMxJGVnNSeiqKGSmMEpP7tVNHl +s4V7oyQXd8KX+HpziiayjGy9giVteJJi/bAmUp+0+hTHBes5fOWI5JyDAgMBAAGj +gYAwfjAfBgNVHSMEGDAWgBSmAyntm95+KoyL3ffLBXZKSpk1VTAJBgNVHRMEAjAA +MAsGA1UdDwQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAPBgNVHREECDAGhwR/ +AAABMB0GA1UdDgQWBBTVdJE2lkGsNIU3LlEf3rN5fyaRkjANBgkqhkiG9w0BAQsF +AAOCAQEAo1klH9WMsPWTN9qN3tdud07eatulEKo/0okaph6MJ59ozseOzxrfpwL0 +67Nr8yl+VwZqrRTBurp0n6G+n0j8UHfWjSrAqN4yUHl+heT0HpnLR2FE9YgZEmxR +bPfVbPBef/eJeE7/U6imfBYzzMajua+hg05sVHUNNdPaFOP+Xj47x8uQmf9w5/kf +MrylRUSgH5RRge4+2T5hmNM9tHfF6OfDHitrXnl+X6h/x/tkBvDcUXtKa5xuEcSg +WpmJKl3pKfXvdmCIrj9Vca+UD2Bntkk2jgDTLEPJAxMgrsQRhnUJclaunnd1NQbc +FmjFW7iaNvDVKt+vYqH8ff8U9iCB2g== +-----END CERTIFICATE----- diff --git a/testdata/leaf.key b/testdata/leaf.key new file mode 100644 index 0000000..0fee211 --- /dev/null +++ b/testdata/leaf.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDHwI/xSLgCuQrx ++WsvupA8w4eMdSefWGw523OJYPJkDYJGgSGsjVb9htba6vXYbGNohuluEAZIyT7G +vmPezTokeVMkuSYTlSV8xplFEtDlQhTzaI/cofbi7qtT91/5zS/w0JSaNosThGtZ +g/M4ZOiMj04m0NGKZz56l9Lpe/yM7fPda++D9xYEGSSdwK9CqqwF+cXN09d6IK1V +INIIjT3Sdb9SssokGWmD7UUPLvwZ5379+HRs1K8AFXqvbkeWVYtrJwJMxJGVnNSe +iqKGSmMEpP7tVNHls4V7oyQXd8KX+HpziiayjGy9giVteJJi/bAmUp+0+hTHBes5 +fOWI5JyDAgMBAAECggEADanzbrrds3n68LByD5LAeRea9xWwfyrqRE7pqVUTX5q4 +9Z+xsP7+G1uU6Oa3qHVJm4XXA+tesq3peGjfpgb92i7ebB2qKB7EsLNZGqt91KDf +lALsDFib7cwLtjOuwgSyKdPqxl3Cx7QAL+Bhy9LDQZIv82HHY6NKV9J43/XWQcGK +KNZyS1o0vBWvt135YeE3qfQA9Ww8GI3jWyk49QDOVtVNZ1HRQpXPkt5exAyU8JpW +Y3Y5VqyEcKPBRlw/scEc5CRuzIP3P06Y+NEuuvnlnDt/BR/wyyuPiZoIqGXhXUKe +oDzEmtVrvB5RmhRc1PoS1l1GBfBfVqwkLUmeSitn8QKBgQD1pC6Ukiqtm7vyhirU +ynE+Dik/gHBBW6fQalD7yZwStid1+HfIvoU+RB0wyPUVwbu7eOoMnjG2ChLYX7cV +UhAcu5ZWFhlc8OS5bGGMI99d4ueBqTYONqnCV3DhiWZhs8OesiF3hohE6jV3G5xC +ra8DzImMpujyMIWXvOwnPLRWCQKBgQDQLPwTvx472XbY1aWviQkzxMgxP8l9D+lO +nBaybarLxDa+89RMWidgJX2kGM7i6FgicGgSNpNDIYNLrVZYAz11PP6o2Oqa9ZaR +5IvnfhP1iOwSgIoC8weNSE+Y0Lw/w0IOW71+XsfgswhJG45eXV8hRqYpUEkjjl1x +nQM0hCshKwKBgCQDFfEiHK+nDT7Y/J6Fr2Rxnwp4QfzS+x9K9uRzAjacDdz1uFnt +1Ir0YXMtgwDVjjhF2cpPxunxQCIIpkax6TrNJZUpWD6P8nhcs1BgUfbptRcFP6+F +xA2B1EK8ag4Y1K0HYHCtgHzZ+Uyk95uu6uGbsu6z6aLYCj3crKJz+9xBAoGACOdT +pLiQ33hul9mTa42N4jPxaAHVaU7r6JvOcLU2D98FhGdDVjyo4HjaBdG1z4imdFqg +aN8Cr2VYiz0Pq1YAI+qG7cvRRO1qEjVXMoB29BJ2Hlh3Dqc8VHOaS+vpkUSVp62O +zj/ZhqfBm/bcwPZ3YiH2a1/usOGe54QSpgVdHt8CgYBr6xpKQSrQvgtiuv9kCzI+ +WQYP2Xxj+zsQb29hagXY/JllKOl5aDGz2qMV2RgOWZabB/xxkAeTF4A26wWrmcq0 +wsv96jEl1MtI3lB3bi/8Y/tctkNsp0drvDZdfnGMpzxDafECKdxdFvhO63p7yBOA +LG1OvFTywkxBuOUKsNsErQ== +-----END PRIVATE KEY----- diff --git a/testdata/rootCA.crt b/testdata/rootCA.crt new file mode 100644 index 0000000..a6b6bda --- /dev/null +++ b/testdata/rootCA.crt @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDHzCCAgegAwIBAgIUUmc9nWf4fhGFNd0oCNE0CzOXMaEwDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MDIyODEyMDEyOVoXDTI3 +MTIxOTEyMDEyOVowFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw5ZFoDGTL0YyrwEA7qTu32cur0sQXDV86Xwl +G0ilk2DXXH4F70ruTTW3NG0Rniw/rt2jzJADo1Tlosq9eJKQGQKAr21N5kjhlU3J +8nFBK+1WJyG27EvyeqZOCucXOJaAm0HSbhlT0MYpZ4kzuxmOUPmTsJmt2BtK+uRU +3LlXtzyZnJo53azQuLZz26tBGd9LXsBUMi+KJ0eX1HPluIT3o+nslnJZaqGySLKm +cJnLf9hio+rAwFBb8sgDdzeI7jqZ2bmAGPJBYpIT/dIxuZUkgTfX+OMp2g3RnQea +M0w0UjhbbQeAJONH9HGREDdp7tYtuyuBbE4miNTyjSsouqk6AwIDAQABo2MwYTAd +BgNVHQ4EFgQUfd/q0BY4fkVBV3X+HWzXH0toW08wHwYDVR0jBBgwFoAUfd/q0BY4 +fkVBV3X+HWzXH0toW08wDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8EBAMCAQYw +DQYJKoZIhvcNAQELBQADggEBAGLUya6xwaCwUPWHNOjlvGBGzGwAmSstJzh5o77O +XTTvyCwb0p80AnS9XoX3An5e4ePzw69mEw6RzfVLIex7fCRDekqPFuSWjVagKGJc +G7nvCqdHoCh2z1Jkb9gFpYPd6p45dtLWBw9e9/t9cFHtDR6stC16/Hy8cLzEIr0c +EWxCNdJdZW+soJivaZQeVWtlMXxVpGIs8i33CAFYufZCTKMgyYRegZuMQ676OcDE +9VSi2vJnnhdn7OBip82xX3NDQrwVt60fvFMr25cPOlzhXRY4mQLslGOleqT3sSPV +DVJnOBBmdjgFQQ8BO7rFUNGGOaUcEZp0HLRwxPZyc6OBCIg= +-----END CERTIFICATE----- diff --git a/testdata/server.crt b/testdata/server.crt new file mode 100644 index 0000000..6093005 --- /dev/null +++ b/testdata/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDOjCCAiKgAwIBAgIUQr7R8yN5+2and6ucUOPF6oIbD48wDQYJKoZIhvcNAQEL +BQAwFzEVMBMGA1UEAwwMVGVzdCBSb290IENBMB4XDTI1MDIyODEyMDEzMFoXDTI2 +MDcxMzEyMDEzMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0B +AQEFAAOCAQ8AMIIBCgKCAQEA4oL2hAPQlDVaNJru5fIstkpoVSuam0vpswC7ciRc +XQRjF3q8kjtIA7+jdySsKJqOLGnybDX3awvRyKMEjq11IfnZLjZc+FzTlA+x4z0h +MHb0GiBFXKNzrExGI9F0KEPtFxcMIqZ119LY2ReexxWkZBQYlgTepaevp71za4c2 +n4Zy1+0iS5+uklZ4ANKMTBGlN76Qgt530VnpNiIeUbiUzY58Vx4q7kFcUv/oSz8p +rbXr+/GGpAjrOc6/JsezRE8YK2po60dvV80TJ2Jt6pduvF7OSQnq/v4mJl1xuXKl +Byo9HLbeu3BuVRWQs2/EwEzx5kX3Ugysl9Bm44K2yKe9/QIDAQABo4GAMH4wHwYD +VR0jBBgwFoAUfd/q0BY4fkVBV3X+HWzXH0toW08wCQYDVR0TBAIwADALBgNVHQ8E +BAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwDwYDVR0RBAgwBocEfwAAATAdBgNV +HQ4EFgQUe0rTTfWjho3hgeLTnajTCpddo2MwDQYJKoZIhvcNAQELBQADggEBAIR2 +5zkA7rPnddxCunsz8Jjq3wyhR/KiAFz+RGeFeiXDkF2fWr7QIQ9KbFbv8tpfXR7P +B75bY0sXwutHMB2sZDi92cH5sthNBfp19fI35cxcU4oTPxp4UZJKEiA3Qx8y73CX +NJu1009nPdOJNlIboDGAFdZ5SH6RCh+YcQZ68kjHPWBIpXxLbs9FN3QmpbAvtLh1 +PoPaSy7IjKmxm1u+Lf6tyIn2IiB3MiynaB3OKvbkLCseM/5SZKMk6WKSDWopOCJr +xciPOc+yeLz5I2Omn0uViOIIciqjlgxncWAyNtDgvJcecwqB2cPiIhk6GY0QZ1uM +e7KoqGzWXvWLqJ13a9U= +-----END CERTIFICATE----- diff --git a/testdata/server.key b/testdata/server.key new file mode 100644 index 0000000..064c6c1 --- /dev/null +++ b/testdata/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDigvaEA9CUNVo0 +mu7l8iy2SmhVK5qbS+mzALtyJFxdBGMXerySO0gDv6N3JKwomo4safJsNfdrC9HI +owSOrXUh+dkuNlz4XNOUD7HjPSEwdvQaIEVco3OsTEYj0XQoQ+0XFwwipnXX0tjZ +F57HFaRkFBiWBN6lp6+nvXNrhzafhnLX7SJLn66SVngA0oxMEaU3vpCC3nfRWek2 +Ih5RuJTNjnxXHiruQVxS/+hLPymttev78YakCOs5zr8mx7NETxgramjrR29XzRMn +Ym3ql268Xs5JCer+/iYmXXG5cqUHKj0ctt67cG5VFZCzb8TATPHmRfdSDKyX0Gbj +grbIp739AgMBAAECggEADgUIbbAFbJbyHV1q5Jqc/9oSeRW40lyG0Mh+fEMZ4Gam +x3ZA+QAS+1W/hV6ktTf+YsCv+4NKQWWQN3iM41PYcyDmu1XWt/Hu5TQk0NQgxhd8 +EP3nAnkvbf5OkmWiveHuaRvJFCqfZ/Cp8U3lSvHg+edwhMs1CKXHWSeAXwBrIMEb +ajpxuD3B/NT/CGmKnj3cgAuIbvNHVIcwu8ACbpczDL++vi7KrWmOJn1QzSlUlNFi +fsgnF0heO5Uff4vkjXU84INQxOP3tbvXcDNiwDewZy75h2d3Pv+ku8GoZYWFUXSJ +yKtafJMJUD0kJMuKhkzrwYcQGY6ioSYisPK+JoungQKBgQD8fWmuHwCXbM4Ckyns +Wg4f+kG8d+wypgIs6ENmgr9UnNB0N6n7nO7v/4l/l7IN9CQQmdtSvek2ytk5rGBM +XUAWxZaokE3MecxR0EUJx42k/k3dN4XgU/YNk4D6/wpEsyUATE4nIFDVjxE+Jc07 +CZ2CUWKyxTPGz2kfHnEQ0vFiYQKBgQDlqRiGlJ6c99zTas2wrvr+50aQhn6BryDK +kjGM6woPnnwMq+Jy6vum3o0cU+iNNeFAijShXo2XR3iZJcoJ2sPhy3dRWdBNdFyy +hwxgD0cXzEjQL0M03DPDykTnM7ZvE6KUZjnxJZkytJHLKapoGzxBH9656zx5qnuH +MPYwTWg5HQKBgDKBD4OBtgeT/v0q3KbnOI4S69U8E6Xp6ON8rgayPn05RMUKYVjw +AidFcQZxnG8IF7KuY92AGUcZeiv8G+MKgAhOC526B6XP8xumUjjrjpyjNYX7Vi8R +/FSo3ZLXMwGc59jQao2O/DxLesJ4oz2c5cGsb9acdYfd8wQDfdBEsX3hAoGAc4Pu +NiMi9MknZZ/e/fPFg9lIgQFlOE2iLMID8mF2mgyZULZUHIFdOr3ONGVwHzbuqcva +VSB+D41/d2iuiu5igHwa8+w8/fh9d7691sNYevvh0/Ux1LC9yMlAhxpXtN8nc4VH +t6e1uu9gNdQrRloMoKUrHlDYBkpd/838xqbouXECgYEArwn+eXKD5zgNN4jEbNBp +ygIp+Oh2abt+CNQjfLUa+qon5ziH53mHixJ2hpaOa6Rxxu9R2ZgNLtbodm+ccD8z +ZNA7Z0rApAwfuhD8zIzkZ4HuARN8eopYmTubpzDkAcfRWhw1EBDQc0V6trl+EJsK +xfbmGepRVWXw2dLmxhA9/zM= +-----END PRIVATE KEY----- diff --git a/types.go b/types.go new file mode 100644 index 0000000..851039f --- /dev/null +++ b/types.go @@ -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"` +}