44 Commits

Author SHA1 Message Date
Francesco Renzi 33cbb7a1d9 log 2026-02-02 12:07:47 +01:00
Francesco Renzi 9cd2ceb86b static user agent 2026-02-02 11:00:34 +01:00
Francesco Renzi aeda0a5af4 Add --labels support in docker example (#51) 2026-01-30 13:41:46 +00:00
Nikola Jokic c841c96f1c Add debug info while issuing request (#50)
* Add debug info while issuing request

* wip

* wip

* Rework tests
2026-01-30 13:03:22 +01:00
Francesco Renzi 145b7e382f Add more details about the API in the readme (#47) 2026-01-13 17:19:32 +00:00
Francesco Renzi 0015641f99 Remove unused fields (#48) 2026-01-13 17:19:16 +00:00
Nikola Jokic 1208e1216e Hide session handling and generate session client from the main client (#42) 2026-01-05 18:19:41 +01:00
Francesco Renzi ebe67dba45 Add missing sections to readme (#45) 2026-01-02 16:53:12 +00:00
Francesco Renzi 5cb859334b Add CONTRIBUTING.md (#44) 2026-01-02 16:52:57 +00:00
Francesco Renzi 3fe477d93e Add SUPPORT.md (#43)
Updated support instructions and removed TODOs.
2026-01-02 10:44:46 -06:00
Nikola Jokic f3ab19d2b8 Use system info and build info as user agent, and expose DebugInfo (#41)
* Refactor setting and unsetting system info

* detect version on init

* Update client.go

* Update client_test.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update client.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-17 16:29:45 +01:00
Francesco Renzi 40342b1e2a Add CODEOWNERS file for repository ownership (#40) 2025-12-08 15:08:51 +01:00
Francesco Renzi d9d8d097da Add Contributor Covenant Code of Conduct (#38) 2025-12-04 11:48:21 +00:00
Francesco Renzi e346584c2d Create SECURITY.md for reporting vulnerabilities (#39)
Added guidelines for reporting security issues and policies.
2025-12-04 11:48:07 +00:00
Francesco Renzi be0376b5b5 Add MIT License to the project (#37) 2025-12-04 12:32:40 +01:00
Nikola Jokic 7e0eae99a6 Modify error API and add Listener tests (#36)
* Modify error API to not include exception, but to test on type

* reformat

* address renames and documentations from the review

* test is errors

* document
2025-12-02 18:23:40 +01:00
Nikola Jokic 01adae723f Implement dockerscaleset e2e tests (#25)
* wip: setting up e2e tests

* rebase
2025-11-27 17:18:00 +01:00
Francesco Renzi e37307f46f Use retryablehttp for token calls too (#35) 2025-11-27 12:36:11 +00:00
Nikola Jokic 23bfd4848d Remove example module and revert back to ints for simpler usage (#34)
* Remove example module and revert back to ints for simpler usage

* fmt

* remove unused transformations

* add max runners to the scaler
2025-11-27 13:31:44 +01:00
Francesco Renzi a2a8af97b6 Add scripts (#33)
* Add scripts

* Fix errors test
2025-11-27 13:05:42 +01:00
Francesco Renzi 60ce378d14 Enable cache in test jobs (#31) 2025-11-26 17:08:22 +00:00
Francesco Renzi dde6b4631c Deserialize scale messages in the client (#28)
* Deserialize scale messages in the client

* fix tests
2025-11-26 17:08:00 +00:00
Francesco Renzi fe39658434 Update devcontainer configuration with features and settings (#32)
* Update devcontainer configuration with features and settings

* Add devcontainer Dockerfile

* Comment why node

* \n
2025-11-26 17:07:35 +00:00
Francesco Renzi cec89c5b7b Add godoc comments and unexport (#27)
* Add separate constructors for each credential type

* Add godoc comments to client

* Unexport GitHubConfig

* Add godoc comments for listener package

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-26 15:05:39 +00:00
Nikola Jokic 7442885c99 Use strong typing and allow atomic change of the max runners (#24)
* Use strong typing and allow atomic change of the max runners

* prepare type change in dockerscaleset
2025-11-26 12:10:39 +01:00
Francesco Renzi 6d1c317b90 Return error on failed runner start (#17) 2025-11-24 11:06:19 +00:00
Nikola Jokic 45800be002 Add error messages instead of just returning an error (#16)
* Add error messages instead of just returning an error

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-24 12:05:49 +01:00
Nikola Jokic 16a9c915cb Use buildinfo to set the client version in the user agent (#13)
* Use go:generate to set the user agent version and sha

* regenerate

* buildinfo

* remove unused

* use init to source buildinfo
2025-11-21 14:38:55 +00:00
Francesco Renzi c2f0a53402 Add command to work around auth issues (#15) 2025-11-10 10:41:03 +00:00
Francesco Renzi 5f370484a7 Add READMEs (#10)
* Add READMEs

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feedback

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-31 15:15:26 +00:00
Francesco Renzi 8104f571eb Update module name (#12) 2025-10-31 14:25:50 +00:00
Francesco Renzi 4cc97e220a Rename Handler -> Scaler (#11) 2025-10-31 14:06:27 +00:00
Nikola Jokic 8155f87a58 Remove replace in example go.mod (#9) 2025-10-31 14:02:56 +00:00
Nikola Jokic 83fb2e2837 Set user agent as soon as the scale set is created, and remove the proxy info (#8)
* Set user agent as soon as the scale set is created

* fix test
2025-10-31 14:16:02 +01:00
Nikola Jokic 1f22d2441c move testserver to internal package (#7) 2025-10-31 14:10:56 +01:00
Francesco Renzi e9e5ca3282 Consolidate tests in a single file (#6) 2025-10-31 12:00:11 +00:00
Nikola Jokic 51aa04a4b2 Add Docker example (#5)
* Renames, style changes and unesports

* use signal handling

* remove unused fields, unexport packages, have a working example

* remove viper

* fix logger

* fix test

* remove unused var

* Added few log lines

* max runners fixed

* Restructure example

* Improve naming for app auth

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove old comment

---------

Co-authored-by: Francesco Renzi <rentziass@gmail.com>
Co-authored-by: Francesco Renzi <rentziass@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-31 11:35:41 +00:00
Francesco Renzi 39ffa2e560 Remove acquire jobs (#4) 2025-10-30 14:52:11 +01:00
Nikola Jokic b1dcbc44e6 Renames, style changes and unexports (#3) 2025-10-30 14:24:07 +01:00
Francesco Renzi 287d3410da Merge pull request #2 from actions/nikola-jokic/remove-interface-and-mocks
Remove interfaces, mocks, and unused identity_test
2025-10-30 10:42:13 +00:00
Nikola Jokic c549549283 Remove interfaces, mocks, and unused identity_test 2025-10-30 11:35:35 +01:00
Francesco Renzi a2aa9f9ea9 Merge pull request #1 from actions/rentziass/ginkgo
Remove dependency on ginkgo
2025-10-30 10:24:51 +00:00
Francesco Renzi dd4c9f2481 Remove dependency on ginkgo 2025-10-30 10:21:06 +00:00
Nikola Jokic 8b43e9e5c9 Initial commit containing ARC github/actions client 2025-10-30 10:15:01 +01:00
10 changed files with 84 additions and 457 deletions
+1 -1
View File
@@ -65,4 +65,4 @@ jobs:
go-version-file: "go.mod"
cache: true
- name: Run tests
run: go test ./... -race
run: go test ./...
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright GitHub, Inc.
Copyright (c) 2025 GitHub
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+3 -3
View File
@@ -1,6 +1,6 @@
# GitHub Actions Runner Scale Set Client (Public Preview)
# GitHub Actions Runner Scale Set Client (Private Preview)
> Status: **Public Preview** While the API is stable, interfaces and examples in this repository may change.
> 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.
@@ -12,7 +12,7 @@ You do *not* need to adopt the full controller (and Kubernetes) to take advantag
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`). Multiple labels can be assigned per scale set. Like regular self-hosted runners, scale sets can be registered at the repository, organization, or enterprise level.
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`).
+6 -5
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"maps"
"net/http"
"net/url"
@@ -126,9 +127,9 @@ type ProxyFunc func(req *http.Request) (*url.URL, error)
type SystemInfo struct {
// System is the name of the scale set implementation
System string `json:"system"`
// Version is the version of the client
// Version is the version of the controller
Version string `json:"version"`
// CommitSHA is the git commit SHA of the client
// CommitSHA is the git commit SHA of the controller
CommitSHA string `json:"commit_sha"`
// ScaleSetID is the ID of the scale set
ScaleSetID int `json:"scale_set_id"`
@@ -222,7 +223,6 @@ 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) {
@@ -546,6 +546,7 @@ func parseRunnerScaleSetMessageResponse(respBody io.Reader) (*RunnerScaleSetMess
// 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
@@ -566,8 +567,6 @@ func (c *Client) MessageSessionClient(ctx context.Context, runnerScaleSetID int,
scaleSetID: runnerScaleSetID,
session: nil,
}
// Unlock the client to allow createMessageSession to call public methods that require locking
c.mu.Unlock()
if err := client.createMessageSession(ctx); err != nil {
return nil, fmt.Errorf("failed to create message session: %w", err)
@@ -837,6 +836,8 @@ func (c *Client) getActionsServiceAdminConnection(ctx context.Context, rt *regis
return nil, fmt.Errorf("failed to get actions service admin connection: %w", err)
}
slog.Info("got connection", *adminConnection.ActionsServiceURL, c.userAgent)
return adminConnection, nil
}
+12 -55
View File
@@ -75,20 +75,12 @@ func sendRequest(c *http.Client, req *http.Request) (*http.Response, error) {
}
type httpClientOption struct {
logger *slog.Logger
// Options for built-in retryable HTTP client.
// Ignored if a custom retryable HTTP client is provided via WithRetryableHTTPClint.
retryMax int
retryWaitMax time.Duration
// fields added to the transport if specified
logger *slog.Logger
retryMax int
retryWaitMax time.Duration
rootCAs *x509.CertPool
tlsInsecureSkipVerify bool
proxyFunc ProxyFunc
timeout time.Duration
retryableHTTPClient *retryablehttp.Client
}
func (o *httpClientOption) defaults() {
@@ -101,28 +93,14 @@ func (o *httpClientOption) defaults() {
if o.retryWaitMax == 0 {
o.retryWaitMax = 30 * time.Second
}
if o.timeout == 0 {
o.timeout = 5 * time.Minute
}
}
func (o *httpClientOption) newRetryableHTTPClient() (*retryablehttp.Client, error) {
var retryClient *retryablehttp.Client
if o.retryableHTTPClient != nil {
retryClient = o.retryableHTTPClient
} else {
retryClient = retryablehttp.NewClient()
retryClient.RetryMax = o.retryMax
retryClient.RetryWaitMax = o.retryWaitMax
}
if retryClient.HTTPClient.Timeout == 0 {
retryClient.HTTPClient.Timeout = o.timeout
}
if retryClient.Logger == nil {
retryClient.Logger = o.logger
}
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 {
@@ -142,9 +120,7 @@ func (o *httpClientOption) newRetryableHTTPClient() (*retryablehttp.Client, erro
transport.TLSClientConfig.InsecureSkipVerify = true
}
if o.proxyFunc != nil {
transport.Proxy = o.proxyFunc
}
transport.Proxy = o.proxyFunc
retryClient.HTTPClient.Transport = transport
@@ -161,30 +137,18 @@ func (c *commonClient) setUserAgent() {
SystemInfo: c.systemInfo,
BuildVersion: buildInfo.version,
BuildCommitSHA: buildInfo.commitSHA,
Kind: "scaleset",
})
c.userAgent = string(b)
slog.Info("user agent", "useragent", c.userAgent)
}
// HTTPOption defines a functional option for configuring the Client.
type HTTPOption func(*httpClientOption)
// WithRetryableHTTPClint allows users to provide a custom retryable HTTP client.
// If not set, a default client will be used with the specified retry and timeout settings.
func WithRetryableHTTPClint(client *retryablehttp.Client) HTTPOption {
return func(c *httpClientOption) {
c.retryableHTTPClient = client
}
}
// WithLogger sets a custom logger for the Client.
// If nil is passed, a discard logger will be used.
func WithLogger(logger *slog.Logger) HTTPOption {
func WithLogger(logger slog.Logger) HTTPOption {
return func(c *httpClientOption) {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
c.logger = logger
c.logger = &logger
}
}
@@ -222,10 +186,3 @@ func WithProxy(proxyFunc ProxyFunc) HTTPOption {
c.proxyFunc = proxyFunc
}
}
// WithTimeout sets a timeout for the Client.
func WithTimeout(duration time.Duration) HTTPOption {
return func(c *httpClientOption) {
c.timeout = duration
}
}
-89
View File
@@ -7,10 +7,8 @@ import (
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/actions/scaleset/internal/testserver"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/net/http/httpproxy"
@@ -126,7 +124,6 @@ func TestUserAgent(t *testing.T) {
SystemInfo: testSystemInfo,
BuildCommitSHA: sha,
BuildVersion: version,
Kind: "scaleset",
}
b, err := json.Marshal(wantInfo)
require.NoError(t, err, "failed to marshal expected user agent")
@@ -147,7 +144,6 @@ func TestUserAgent(t *testing.T) {
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")
@@ -155,88 +151,3 @@ func TestUserAgent(t *testing.T) {
assert.Equal(t, want, got)
}
// TestWithRetryableHTTPClient verifies that a custom retryable HTTP client
// provided via WithRetryableHTTPClient is actually used instead of the built-in one
func TestWithRetryableHTTPClient(t *testing.T) {
t.Run("uses custom retryable client instead of built-in", func(t *testing.T) {
attemptCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attemptCount++
if attemptCount == 1 {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"result": "success"}`))
}))
defer server.Close()
// Create a custom retryable HTTP client with specific retry configuration
customRetryClient := retryablehttp.NewClient()
customRetryClient.RetryMax = 3
customRetryClient.RetryWaitMax = 10 * time.Millisecond
// Create options with the custom retryable client
opts := defaultHTTPClientOption()
WithRetryableHTTPClint(customRetryClient)(&opts)
// Verify that the custom client is set in options
assert.NotNil(t, opts.retryableHTTPClient)
assert.Equal(t, customRetryClient, opts.retryableHTTPClient)
// Create the common client with custom retryable client
client := newCommonClient(testSystemInfo, opts)
// Make a request that will trigger a retry
req, err := http.NewRequest("GET", server.URL, nil)
require.NoError(t, err)
resp, err := client.do(req)
require.NoError(t, err)
// Should succeed after retry
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, 2, attemptCount)
// Verify that the client used is the custom one by checking newRetryableHTTPClient
retrievedRetryClient, err := client.newRetryableHTTPClient()
require.NoError(t, err)
assert.Equal(t, customRetryClient, retrievedRetryClient, "should return the custom retryable client")
})
t.Run("respects custom client's retry configuration over built-in defaults", func(t *testing.T) {
attemptCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
attemptCount++
w.WriteHeader(http.StatusServiceUnavailable)
}))
defer server.Close()
// Create custom client with limited retries
customRetryClient := retryablehttp.NewClient()
customRetryClient.RetryMax = 1 // Only 1 retry (2 total attempts)
customRetryClient.RetryWaitMax = 5 * time.Millisecond
opts := defaultHTTPClientOption()
WithRetryableHTTPClint(customRetryClient)(&opts)
client := newCommonClient(testSystemInfo, opts)
req, err := http.NewRequest("GET", server.URL, nil)
require.NoError(t, err)
resp, err := client.do(req)
// When all retries are exhausted with a retryable error, the client gives up
// and an error is returned
if err != nil {
// Expected: request failed after exhausting retries
assert.Contains(t, err.Error(), "giving up after 2 attempt(s)")
} else {
// Or the final response is returned
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
// Should have tried 1 initial + 1 retry = 2 times total
assert.Equal(t, 2, attemptCount)
})
}
+10 -65
View File
@@ -52,57 +52,22 @@ type Client interface {
Session() scaleset.RunnerScaleSetSession
}
// MetricsRecorder defines the hook methods that will be called by the listener at
// various points in the message handling process. This allows for custom
// metrics to be collected without coupling the listener to a specific metrics
// implementation. The methods in this interface will be called with relevant
// information about the message handling process, such as the number of jobs
// started/completed, the desired runner count, and any errors that occur.
// Implementers can use this information to track the performance and behavior
// of the listener and the scaleset service.
type MetricsRecorder interface {
RecordStatistics(statistics *scaleset.RunnerScaleSetStatistic)
RecordJobStarted(msg *scaleset.JobStarted)
RecordJobCompleted(msg *scaleset.JobCompleted)
RecordDesiredRunners(count int)
}
type discardMetricsRecorder struct{}
func (d *discardMetricsRecorder) RecordStatistics(statistics *scaleset.RunnerScaleSetStatistic) {}
func (d *discardMetricsRecorder) RecordJobStarted(msg *scaleset.JobStarted) {}
func (d *discardMetricsRecorder) RecordJobCompleted(msg *scaleset.JobCompleted) {}
func (d *discardMetricsRecorder) RecordDesiredRunners(count int) {}
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
metricsRecorder MetricsRecorder
client Client
// Configuration for the listener
scaleSetID int
maxRunners atomic.Uint32
latestStatistics *scaleset.RunnerScaleSetStatistic
scaleSetID int
maxRunners atomic.Uint32
// configuration for the listener
logger *slog.Logger
}
type Option func(*Listener)
// WithMetricsRecorder sets the MetricsRecorder for the listener. If not set, a no-op recorder will be used.
// If the nil is passed, the MetricsRecorder will not be updated and the existing one will be used (which is a no-op by default).
func WithMetricsRecorder(recorder MetricsRecorder) Option {
return func(l *Listener) {
if recorder == nil {
return
}
l.metricsRecorder = recorder
}
}
// 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) {
@@ -120,17 +85,12 @@ func New(client Client, config Config, options ...Option) (*Listener, error) {
}
listener := &Listener{
client: client,
metricsRecorder: &discardMetricsRecorder{},
scaleSetID: config.ScaleSetID,
logger: config.Logger,
client: client,
scaleSetID: config.ScaleSetID,
logger: config.Logger,
}
listener.SetMaxRunners(config.MaxRunners)
for _, option := range options {
option(listener)
}
return listener, nil
}
@@ -154,17 +114,13 @@ func (l *Listener) Run(ctx context.Context, scaler Scaler) error {
return fmt.Errorf("session statistics is nil")
}
l.handleStatistics(ctx, initialSession.Statistics)
l.logger.Info(
"Handling initial session statistics",
slog.Int("totalAssignedJobs", initialSession.Statistics.TotalAssignedJobs),
)
desiredCount, err := scaler.HandleDesiredRunnerCount(ctx, initialSession.Statistics.TotalAssignedJobs)
if err != nil {
if _, err := scaler.HandleDesiredRunnerCount(ctx, initialSession.Statistics.TotalAssignedJobs); err != nil {
return fmt.Errorf("handling initial message failed: %w", err)
}
l.metricsRecorder.RecordDesiredRunners(desiredCount)
}
var lastMessageID int
@@ -186,7 +142,7 @@ func (l *Listener) Run(ctx context.Context, scaler Scaler) error {
}
if msg == nil {
_, err := scaler.HandleDesiredRunnerCount(ctx, l.latestStatistics.TotalAssignedJobs)
_, err := scaler.HandleDesiredRunnerCount(ctx, 0)
if err != nil {
return fmt.Errorf("handling nil message failed: %w", err)
}
@@ -204,35 +160,24 @@ func (l *Listener) Run(ctx context.Context, scaler Scaler) error {
}
func (l *Listener) handleMessage(ctx context.Context, handler Scaler, msg *scaleset.RunnerScaleSetMessage) error {
l.handleStatistics(ctx, msg.Statistics)
if err := l.client.DeleteMessage(ctx, msg.MessageID); err != nil {
return fmt.Errorf("failed to delete message: %w", err)
}
for _, jobStarted := range msg.JobStartedMessages {
l.metricsRecorder.RecordJobStarted(jobStarted)
if err := handler.HandleJobStarted(ctx, jobStarted); err != nil {
return fmt.Errorf("failed to handle job started: %w", err)
}
}
for _, jobCompleted := range msg.JobCompletedMessages {
l.metricsRecorder.RecordJobCompleted(jobCompleted)
if err := handler.HandleJobCompleted(ctx, jobCompleted); err != nil {
return fmt.Errorf("failed to handle job completed: %w", err)
}
}
desiredCount, err := handler.HandleDesiredRunnerCount(ctx, msg.Statistics.TotalAssignedJobs)
if err != nil {
if _, err := handler.HandleDesiredRunnerCount(ctx, msg.Statistics.TotalAssignedJobs); err != nil {
return fmt.Errorf("failed to handle desired runner count: %w", err)
}
l.metricsRecorder.RecordDesiredRunners(desiredCount)
return nil
}
func (l *Listener) handleStatistics(ctx context.Context, msg *scaleset.RunnerScaleSetStatistic) {
l.latestStatistics = msg
l.metricsRecorder.RecordStatistics(msg)
}
+51 -48
View File
@@ -75,37 +75,39 @@ func TestListener_Run(t *testing.T) {
client := NewMockClient(t)
uuid := uuid.New()
initialStatistics := &scaleset.RunnerScaleSetStatistic{
TotalAssignedJobs: 2,
}
session := scaleset.RunnerScaleSetSession{
SessionID: uuid,
OwnerName: "example",
RunnerScaleSet: &scaleset.RunnerScaleSet{},
MessageQueueURL: "https://example.com",
MessageQueueAccessToken: "1234567890",
Statistics: initialStatistics,
Statistics: &scaleset.RunnerScaleSetStatistic{},
}
client.On("Session").Return(session).Once()
metricsRecorder := NewMockMetricsRecorder(t)
metricsRecorder.On("RecordStatistics", initialStatistics).Once()
metricsRecorder.On("RecordDesiredRunners", initialStatistics.TotalAssignedJobs).
Return(initialStatistics.TotalAssignedJobs, nil).
Run(func(mock.Arguments) { cancel() }).
Once()
l, err := New(client, config, WithMetricsRecorder(metricsRecorder))
l, err := New(client, config)
require.Nil(t, err)
var called bool
handler := NewMockScaler(t)
handler.On("HandleDesiredRunnerCount", mock.Anything, mock.Anything).
Return(initialStatistics.TotalAssignedJobs, nil).
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) {
@@ -118,60 +120,61 @@ func TestListener_Run(t *testing.T) {
MaxRunners: 10,
}
client := NewMockClient(t)
uuid := uuid.New()
initialStatistics := &scaleset.RunnerScaleSetStatistic{
TotalAssignedJobs: 2,
}
session := scaleset.RunnerScaleSetSession{
SessionID: uuid,
OwnerName: "example",
RunnerScaleSet: &scaleset.RunnerScaleSet{},
MessageQueueURL: "https://example.com",
MessageQueueAccessToken: "1234567890",
Statistics: initialStatistics,
Statistics: &scaleset.RunnerScaleSetStatistic{},
}
msg := &scaleset.RunnerScaleSetMessage{
MessageID: 1,
Statistics: &scaleset.RunnerScaleSetStatistic{
TotalAssignedJobs: 3,
},
MessageID: 1,
Statistics: &scaleset.RunnerScaleSetStatistic{},
}
metricsRecorder := NewMockMetricsRecorder(t)
client := NewMockClient(t)
handler := NewMockScaler(t)
client.On("Session").Return(session).Once()
metricsRecorder.On("RecordStatistics", initialStatistics).Once()
metricsRecorder.On("RecordDesiredRunners", initialStatistics.TotalAssignedJobs).
Return(initialStatistics.TotalAssignedJobs, nil).
Once()
handler.On("HandleDesiredRunnerCount", mock.Anything, initialStatistics.TotalAssignedJobs).
Return(initialStatistics.TotalAssignedJobs, nil).
Once()
client.On("GetMessage", ctx, mock.Anything, 10).
client.On(
"GetMessage",
ctx,
mock.Anything,
10,
).
Return(msg, nil).
Run(func(mock.Arguments) { cancel() }).
Run(
func(mock.Arguments) {
cancel()
},
).
Once()
metricsRecorder.On("RecordStatistics", msg.Statistics).Once()
// Ensure delete message is called without cancel
client.On("DeleteMessage", context.WithoutCancel(ctx), mock.Anything).
Return(nil).
client.On(
"DeleteMessage",
context.WithoutCancel(ctx),
mock.Anything,
).Return(nil).Once()
handler := NewMockScaler(t)
handler.On(
"HandleDesiredRunnerCount",
mock.Anything,
0,
).
Return(0, nil).
Once()
metricsRecorder.On("RecordDesiredRunners", msg.Statistics.TotalAssignedJobs).
Return(msg.Statistics.TotalAssignedJobs, nil).
handler.On(
"HandleDesiredRunnerCount",
mock.Anything,
mock.Anything,
).
Return(0, nil).
Once()
handler.On("HandleDesiredRunnerCount", mock.Anything, msg.Statistics.TotalAssignedJobs).
Return(msg.Statistics.TotalAssignedJobs, nil).
Once()
l, err := New(client, config, WithMetricsRecorder(metricsRecorder))
l, err := New(client, config)
require.Nil(t, err)
err = l.Run(ctx, handler)
-187
View File
@@ -213,193 +213,6 @@ func (_c *MockClient_Session_Call) RunAndReturn(run func() scaleset.RunnerScaleS
return _c
}
// NewMockMetricsRecorder creates a new instance of MockMetricsRecorder. 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 NewMockMetricsRecorder(t interface {
mock.TestingT
Cleanup(func())
}) *MockMetricsRecorder {
mock := &MockMetricsRecorder{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// MockMetricsRecorder is an autogenerated mock type for the MetricsRecorder type
type MockMetricsRecorder struct {
mock.Mock
}
type MockMetricsRecorder_Expecter struct {
mock *mock.Mock
}
func (_m *MockMetricsRecorder) EXPECT() *MockMetricsRecorder_Expecter {
return &MockMetricsRecorder_Expecter{mock: &_m.Mock}
}
// RecordDesiredRunners provides a mock function for the type MockMetricsRecorder
func (_mock *MockMetricsRecorder) RecordDesiredRunners(count int) {
_mock.Called(count)
return
}
// MockMetricsRecorder_RecordDesiredRunners_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordDesiredRunners'
type MockMetricsRecorder_RecordDesiredRunners_Call struct {
*mock.Call
}
// RecordDesiredRunners is a helper method to define mock.On call
// - count int
func (_e *MockMetricsRecorder_Expecter) RecordDesiredRunners(count interface{}) *MockMetricsRecorder_RecordDesiredRunners_Call {
return &MockMetricsRecorder_RecordDesiredRunners_Call{Call: _e.mock.On("RecordDesiredRunners", count)}
}
func (_c *MockMetricsRecorder_RecordDesiredRunners_Call) Run(run func(count int)) *MockMetricsRecorder_RecordDesiredRunners_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 int
if args[0] != nil {
arg0 = args[0].(int)
}
run(
arg0,
)
})
return _c
}
func (_c *MockMetricsRecorder_RecordDesiredRunners_Call) Return() *MockMetricsRecorder_RecordDesiredRunners_Call {
_c.Call.Return()
return _c
}
func (_c *MockMetricsRecorder_RecordDesiredRunners_Call) RunAndReturn(run func(count int)) *MockMetricsRecorder_RecordDesiredRunners_Call {
_c.Run(run)
return _c
}
// RecordJobCompleted provides a mock function for the type MockMetricsRecorder
func (_mock *MockMetricsRecorder) RecordJobCompleted(msg *scaleset.JobCompleted) {
_mock.Called(msg)
return
}
// MockMetricsRecorder_RecordJobCompleted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordJobCompleted'
type MockMetricsRecorder_RecordJobCompleted_Call struct {
*mock.Call
}
// RecordJobCompleted is a helper method to define mock.On call
// - msg *scaleset.JobCompleted
func (_e *MockMetricsRecorder_Expecter) RecordJobCompleted(msg interface{}) *MockMetricsRecorder_RecordJobCompleted_Call {
return &MockMetricsRecorder_RecordJobCompleted_Call{Call: _e.mock.On("RecordJobCompleted", msg)}
}
func (_c *MockMetricsRecorder_RecordJobCompleted_Call) Run(run func(msg *scaleset.JobCompleted)) *MockMetricsRecorder_RecordJobCompleted_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *scaleset.JobCompleted
if args[0] != nil {
arg0 = args[0].(*scaleset.JobCompleted)
}
run(
arg0,
)
})
return _c
}
func (_c *MockMetricsRecorder_RecordJobCompleted_Call) Return() *MockMetricsRecorder_RecordJobCompleted_Call {
_c.Call.Return()
return _c
}
func (_c *MockMetricsRecorder_RecordJobCompleted_Call) RunAndReturn(run func(msg *scaleset.JobCompleted)) *MockMetricsRecorder_RecordJobCompleted_Call {
_c.Run(run)
return _c
}
// RecordJobStarted provides a mock function for the type MockMetricsRecorder
func (_mock *MockMetricsRecorder) RecordJobStarted(msg *scaleset.JobStarted) {
_mock.Called(msg)
return
}
// MockMetricsRecorder_RecordJobStarted_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordJobStarted'
type MockMetricsRecorder_RecordJobStarted_Call struct {
*mock.Call
}
// RecordJobStarted is a helper method to define mock.On call
// - msg *scaleset.JobStarted
func (_e *MockMetricsRecorder_Expecter) RecordJobStarted(msg interface{}) *MockMetricsRecorder_RecordJobStarted_Call {
return &MockMetricsRecorder_RecordJobStarted_Call{Call: _e.mock.On("RecordJobStarted", msg)}
}
func (_c *MockMetricsRecorder_RecordJobStarted_Call) Run(run func(msg *scaleset.JobStarted)) *MockMetricsRecorder_RecordJobStarted_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *scaleset.JobStarted
if args[0] != nil {
arg0 = args[0].(*scaleset.JobStarted)
}
run(
arg0,
)
})
return _c
}
func (_c *MockMetricsRecorder_RecordJobStarted_Call) Return() *MockMetricsRecorder_RecordJobStarted_Call {
_c.Call.Return()
return _c
}
func (_c *MockMetricsRecorder_RecordJobStarted_Call) RunAndReturn(run func(msg *scaleset.JobStarted)) *MockMetricsRecorder_RecordJobStarted_Call {
_c.Run(run)
return _c
}
// RecordStatistics provides a mock function for the type MockMetricsRecorder
func (_mock *MockMetricsRecorder) RecordStatistics(statistics *scaleset.RunnerScaleSetStatistic) {
_mock.Called(statistics)
return
}
// MockMetricsRecorder_RecordStatistics_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RecordStatistics'
type MockMetricsRecorder_RecordStatistics_Call struct {
*mock.Call
}
// RecordStatistics is a helper method to define mock.On call
// - statistics *scaleset.RunnerScaleSetStatistic
func (_e *MockMetricsRecorder_Expecter) RecordStatistics(statistics interface{}) *MockMetricsRecorder_RecordStatistics_Call {
return &MockMetricsRecorder_RecordStatistics_Call{Call: _e.mock.On("RecordStatistics", statistics)}
}
func (_c *MockMetricsRecorder_RecordStatistics_Call) Run(run func(statistics *scaleset.RunnerScaleSetStatistic)) *MockMetricsRecorder_RecordStatistics_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 *scaleset.RunnerScaleSetStatistic
if args[0] != nil {
arg0 = args[0].(*scaleset.RunnerScaleSetStatistic)
}
run(
arg0,
)
})
return _c
}
func (_c *MockMetricsRecorder_RecordStatistics_Call) Return() *MockMetricsRecorder_RecordStatistics_Call {
_c.Call.Return()
return _c
}
func (_c *MockMetricsRecorder_RecordStatistics_Call) RunAndReturn(run func(statistics *scaleset.RunnerScaleSetStatistic)) *MockMetricsRecorder_RecordStatistics_Call {
_c.Run(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 {
-3
View File
@@ -235,9 +235,6 @@ func (c *MessageSessionClient) Session() RunnerScaleSetSession {
}
func (c *MessageSessionClient) doSessionRequest(ctx context.Context, method, path string, requestData io.Reader, expectedResponseStatusCode int, responseUnmarshalTarget any) error {
c.innerClient.mu.Lock()
defer c.innerClient.mu.Unlock()
req, err := c.innerClient.newActionsServiceRequest(ctx, method, path, requestData)
if err != nil {
return fmt.Errorf("failed to create new actions service request: %w", err)