Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 20 additions & 4 deletions runner/scalesets.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,13 +261,29 @@ func (r *Runner) CreateEntityScaleSet(ctx context.Context, entityType params.For
Enabled: &param.Enabled,
}

runnerScaleSet, err := scalesetCli.CreateRunnerScaleSet(ctx, createParam)
if err != nil {
return params.ScaleSet{}, fmt.Errorf("error creating runner scale set: %w", err)
runnerScaleSet, lookupErr := scalesetCli.GetRunnerScaleSetByNameAndRunnerGroup(ctx, int(runnerGroupID), param.Name)
created := false
if lookupErr != nil {
if !errors.Is(lookupErr, runnerErrors.ErrNotFound) {
return params.ScaleSet{}, fmt.Errorf("error finding runner scale set: %w", lookupErr)
}

runnerScaleSet, err = scalesetCli.CreateRunnerScaleSet(ctx, createParam)
if err != nil {
if !errors.Is(err, scalesets.ErrRunnerScaleSetExists) {
return params.ScaleSet{}, fmt.Errorf("error creating runner scale set: %w", err)
}
runnerScaleSet, err = scalesetCli.GetRunnerScaleSetByNameAndRunnerGroup(ctx, int(runnerGroupID), param.Name)
if err != nil {
return params.ScaleSet{}, fmt.Errorf("error finding existing runner scale set: %w", err)
}
} else {
created = true
}
}

defer func() {
if err != nil {
if err != nil && created {
if innerErr := scalesetCli.DeleteRunnerScaleSet(ctx, runnerScaleSet.ID); innerErr != nil {
slog.With(slog.Any("error", innerErr)).ErrorContext(ctx, "failed to cleanup scale set")
}
Expand Down
184 changes: 184 additions & 0 deletions runner/scalesets_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright 2026 Cloudbase Solutions SRL
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.

//go:build testing

package runner

import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"

commonParams "github.com/cloudbase/garm-provider-common/params"
"github.com/cloudbase/garm/auth"
storeMocks "github.com/cloudbase/garm/database/common/mocks"
"github.com/cloudbase/garm/params"
)

const testScaleSetActionsToken = "eyJhbGciOiJub25lIn0.eyJleHAiOjQxNDk5MzYwMDB9."

type scaleSetAPI struct {
server *httptest.Server
existing bool
createConflict bool
createRequests int
deleteRequests int
}

func newScaleSetAPI(t *testing.T) *scaleSetAPI {
t.Helper()

api := new(scaleSetAPI)
api.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/rate_limit":
_, _ = w.Write([]byte(`{"resources":{"core":{"limit":5000,"remaining":5000}}}`))
case strings.HasSuffix(r.URL.Path, "/repos/owner/repo/actions/runners/registration-token"):
_, _ = fmt.Fprintf(w, `{"token":"registration-token","expires_at":%q}`, time.Now().Add(time.Hour).Format(time.RFC3339))
case r.URL.Path == "/actions/runner-registration":
_, _ = fmt.Fprintf(w, `{"url":%q,"token":%q}`, api.server.URL, testScaleSetActionsToken)
case strings.HasPrefix(r.URL.Path, "/_apis/runtime/runnerscalesets"):
api.handleScaleSets(t, w, r)
default:
http.NotFound(w, r)
}
}))
t.Cleanup(api.server.Close)
return api
}

func (a *scaleSetAPI) handleScaleSets(t *testing.T, w http.ResponseWriter, r *http.Request) {
t.Helper()

switch r.Method {
case http.MethodGet:
if a.existing {
_, _ = w.Write([]byte(`{"count":1,"value":[{"id":42,"name":"existing","runnerGroupId":1}]}`))
return
}
_, _ = w.Write([]byte(`{"count":0,"value":[]}`))
case http.MethodPost:
a.createRequests++
if a.createConflict {
a.existing = true
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"Bad Request","details":"failed: \"{\\\"typeName\\\":\\\"GitHub.Actions.Runtime.WebApi.RunnerScaleSetExistsException, GitHub.Actions.Runtime.WebApi\\\"}\""}`))
return
}
a.existing = true
_, _ = w.Write([]byte(`{"id":42,"name":"existing","runnerGroupId":1}`))
case http.MethodDelete:
a.deleteRequests++
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected scale-set request: %s", r.Method)
}
}

func newScaleSetRunner(t *testing.T, api *scaleSetAPI, createErr error) (*Runner, context.Context) {
t.Helper()

ctx := auth.GetAdminContext(context.Background())
store := storeMocks.NewStore(t)
templateID := uint(1)
credentials, err := json.Marshal(params.GithubPAT{OAuth2Token: "token"})
require.NoError(t, err)
entity := params.ForgeEntity{
ID: "entity-id",
Owner: "owner",
Name: "repo",
EntityType: params.ForgeEntityTypeRepository,
Credentials: params.ForgeCredentials{
APIBaseURL: api.server.URL + "/",
UploadBaseURL: api.server.URL + "/",
BaseURL: api.server.URL,
AuthType: params.ForgeAuthTypePAT,
ForgeType: params.GithubEndpointType,
CredentialsPayload: credentials,
},
}
store.EXPECT().GetForgeEntity(ctx, params.ForgeEntityTypeRepository, entity.ID).Return(entity, nil).Once()
store.EXPECT().GetTemplate(ctx, templateID).Return(params.Template{
ID: templateID,
OSType: commonParams.Linux,
ForgeType: params.GithubEndpointType,
}, nil).Once()
store.EXPECT().CreateEntityScaleSet(ctx, entity, mock.MatchedBy(func(param params.CreateScaleSetParams) bool {
return param.ScaleSetID == 42
})).Return(params.ScaleSet{ScaleSetID: 42}, createErr).Once()

return &Runner{store: store}, ctx
}

func createExistingScaleSet(t *testing.T, runner *Runner, ctx context.Context) (params.ScaleSet, error) {
t.Helper()
templateID := uint(1)
return runner.CreateEntityScaleSet(ctx, params.ForgeEntityTypeRepository, "entity-id", params.CreateScaleSetParams{
Name: "existing",
OSType: commonParams.Linux,
TemplateID: &templateID,
})
}

func TestCreateEntityScaleSetAdoptsExistingScaleSet(t *testing.T) {
api := newScaleSetAPI(t)
api.existing = true
runner, ctx := newScaleSetRunner(t, api, nil)

scaleSet, err := createExistingScaleSet(t, runner, ctx)
require.NoError(t, err)
require.Equal(t, 42, scaleSet.ScaleSetID)
require.Zero(t, api.createRequests)
}

func TestCreateEntityScaleSetRecoversCreateConflict(t *testing.T) {
api := newScaleSetAPI(t)
api.createConflict = true
runner, ctx := newScaleSetRunner(t, api, nil)

scaleSet, err := createExistingScaleSet(t, runner, ctx)
require.NoError(t, err)
require.Equal(t, 42, scaleSet.ScaleSetID)
require.Equal(t, 1, api.createRequests)
}

func TestCreateEntityScaleSetDoesNotDeleteAdoptedScaleSet(t *testing.T) {
api := newScaleSetAPI(t)
api.existing = true
runner, ctx := newScaleSetRunner(t, api, errors.New("database unavailable"))

_, err := createExistingScaleSet(t, runner, ctx)
require.Error(t, err)
require.Zero(t, api.deleteRequests)
}

func TestCreateEntityScaleSetDeletesCreatedScaleSetOnDatabaseFailure(t *testing.T) {
api := newScaleSetAPI(t)
runner, ctx := newScaleSetRunner(t, api, errors.New("database unavailable"))

_, err := createExistingScaleSet(t, runner, ctx)
require.Error(t, err)
require.Equal(t, 1, api.createRequests)
require.Equal(t, 1, api.deleteRequests)
}
52 changes: 51 additions & 1 deletion util/github/scalesets/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@
package scalesets

import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"sync"

"github.com/google/go-github/v84/github"
Expand All @@ -28,6 +32,48 @@ import (
"github.com/cloudbase/garm/runner/common"
)

// ErrRunnerScaleSetExists is returned when creating an existing runner scale set.
var ErrRunnerScaleSetExists = errors.New("runner scale set already exists")

type actionsErrorResponse struct {
TypeName string `json:"typeName"`
Details string `json:"details"`
}

func isRunnerScaleSetExistsType(typeName string) bool {
typeName, _, _ = strings.Cut(typeName, ",")
typeName = strings.TrimSpace(typeName)
if index := strings.LastIndexByte(typeName, '.'); index >= 0 {
typeName = typeName[index+1:]
}
return typeName == "RunnerScaleSetExistsException"
}

func isRunnerScaleSetExists(body []byte) bool {
var response actionsErrorResponse
if err := json.Unmarshal(body, &response); err != nil {
return false
}
if isRunnerScaleSetExistsType(response.TypeName) {
return true
}

start := strings.IndexByte(response.Details, '{')
end := strings.LastIndexByte(response.Details, '}')
if start < 0 || end < start {
return false
}
details := response.Details[start : end+1]
var nestedResponse actionsErrorResponse
if err := json.Unmarshal([]byte(details), &nestedResponse); err != nil {
details, err = strconv.Unquote(`"` + details + `"`)
if err != nil || json.Unmarshal([]byte(details), &nestedResponse) != nil {
return false
}
}
return isRunnerScaleSetExistsType(nestedResponse.TypeName)
}

func NewClient(cli common.GithubClient) (*ScaleSetClient, error) {
return &ScaleSetClient{
ghCli: cli,
Expand Down Expand Up @@ -108,7 +154,11 @@ func (s *ScaleSetClient) Do(req *http.Request) (*http.Response, error) {
case 404:
return nil, runnerErrors.NewNotFoundError("resource %s not found: %q", req.URL.String(), string(body))
case 400:
return nil, runnerErrors.NewBadRequestError("bad request while calling %s: %q", req.URL.String(), string(body))
badRequest := runnerErrors.NewBadRequestError("bad request while calling %s: %q", req.URL.String(), string(body))
if isRunnerScaleSetExists(body) {
return nil, fmt.Errorf("%w: %w", ErrRunnerScaleSetExists, badRequest)
}
return nil, badRequest
case 409:
return nil, runnerErrors.NewConflictError("conflict while calling %s: %q", req.URL.String(), string(body))
case 401, 403:
Expand Down
52 changes: 52 additions & 0 deletions util/github/scalesets/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright 2026 Cloudbase Solutions SRL
//
// Licensed under the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
// License for the specific language governing permissions and limitations
// under the License.

package scalesets

import (
"errors"
"net/http"
"net/http/httptest"
"testing"

runnerErrors "github.com/cloudbase/garm-provider-common/errors"
)

func TestDoRecognizesNestedRunnerScaleSetExistsError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
_, _ = w.Write([]byte(`{"error":"Bad Request","details":"error creating runner scale set: failed: \"{\\\"typeName\\\":\\\"GitHub.Actions.Runtime.WebApi.RunnerScaleSetExistsException, GitHub.Actions.Runtime.WebApi\\\"}\""}`))
}))
t.Cleanup(server.Close)

client := &ScaleSetClient{httpClient: server.Client()}
req, err := http.NewRequest(http.MethodPost, server.URL, nil)
if err != nil {
t.Fatal(err)
}

_, err = client.Do(req)
if !errors.Is(err, ErrRunnerScaleSetExists) {
t.Fatalf("expected ErrRunnerScaleSetExists, got %v", err)
}
if !errors.Is(err, runnerErrors.ErrBadRequest) {
t.Fatalf("expected ErrBadRequest, got %v", err)
}
}

func TestRunnerScaleSetExistsTypeRequiresExactName(t *testing.T) {
if isRunnerScaleSetExistsType("NotRunnerScaleSetExistsException") {
t.Fatal("unexpected duplicate classification")
}
}
Loading