Introduce new preview auto-scaling mode for ARC. (#2153)

Co-authored-by: Cory Miller <cory-miller@github.com>
Co-authored-by: Nikola Jokic <nikola-jokic@github.com>
Co-authored-by: Ava Stancu <AvaStancu@github.com>
Co-authored-by: Ferenc Hammerl <fhammerl@github.com>
Co-authored-by: Francesco Renzi <rentziass@github.com>
Co-authored-by: Bassem Dghaidi <Link-@github.com>
This commit is contained in:
Tingluo Huang
2023-01-17 12:06:20 -05:00
committed by GitHub
parent 619667fc3b
commit 622eaa34f8
75 changed files with 26094 additions and 354 deletions

1101
github/actions/client.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,75 @@
package actions_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/assert"
)
func TestGenerateJitRunnerConfig(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
t.Run("Get JIT Config for Runner", func(t *testing.T) {
name := "Get JIT Config for Runner"
want := &actions.RunnerScaleSetJitRunnerConfig{}
response := []byte(`{"count":1,"value":[{"id":1,"name":"scale-set-name"}]}`)
runnerSettings := &actions.RunnerScaleSetJitRunnerSetting{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(response)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.GenerateJitRunnerConfig(context.Background(), runnerSettings, 1)
if err != nil {
t.Fatalf("GenerateJitRunnerConfig got unexepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GenerateJitRunnerConfig(%v) mismatch (-want +got):\n%s", name, diff)
}
})
t.Run("Default retries on server error", func(t *testing.T) {
runnerSettings := &actions.RunnerScaleSetJitRunnerSetting{}
retryClient := retryablehttp.NewClient()
retryClient.RetryWaitMax = 1 * time.Millisecond
retryClient.RetryMax = 1
actualRetry := 0
expectedRetry := retryClient.RetryMax + 1
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, _ = actionsClient.GenerateJitRunnerConfig(context.Background(), runnerSettings, 1)
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
})
}

View File

@@ -0,0 +1,144 @@
package actions_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/assert"
)
func TestAcquireJobs(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
t.Run("Acquire Job", func(t *testing.T) {
name := "Acquire Job"
want := []int64{1}
response := []byte(`{"value": [1]}`)
session := &actions.RunnerScaleSetSession{
RunnerScaleSet: &actions.RunnerScaleSet{Id: 1},
MessageQueueAccessToken: "abc",
}
requestIDs := want
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(response)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
}
got, err := actionsClient.AcquireJobs(context.Background(), session.RunnerScaleSet.Id, session.MessageQueueAccessToken, requestIDs)
if err != nil {
t.Fatalf("CreateRunnerScaleSet got unexepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetRunnerScaleSet(%v) mismatch (-want +got):\n%s", name, diff)
}
})
t.Run("Default retries on server error", func(t *testing.T) {
session := &actions.RunnerScaleSetSession{
RunnerScaleSet: &actions.RunnerScaleSet{Id: 1},
MessageQueueAccessToken: "abc",
}
var requestIDs []int64 = []int64{1}
retryClient := retryablehttp.NewClient()
retryClient.RetryWaitMax = 1 * time.Millisecond
retryClient.RetryMax = 1
actualRetry := 0
expectedRetry := retryClient.RetryMax + 1
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
}
_, _ = actionsClient.AcquireJobs(context.Background(), session.RunnerScaleSet.Id, session.MessageQueueAccessToken, requestIDs)
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
})
}
func TestGetAcquirableJobs(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
t.Run("Acquire Job", func(t *testing.T) {
name := "Acquire Job"
want := &actions.AcquirableJobList{}
response := []byte(`{"count": 0}`)
runnerScaleSet := &actions.RunnerScaleSet{Id: 1}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(response)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.GetAcquirableJobs(context.Background(), runnerScaleSet.Id)
if err != nil {
t.Fatalf("GetAcquirableJobs got unexepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetAcquirableJobs(%v) mismatch (-want +got):\n%s", name, diff)
}
})
t.Run("Default retries on server error", func(t *testing.T) {
runnerScaleSet := &actions.RunnerScaleSet{Id: 1}
retryClient := retryablehttp.NewClient()
retryClient.RetryWaitMax = 1 * time.Millisecond
retryClient.RetryMax = 1
actualRetry := 0
expectedRetry := retryClient.RetryMax + 1
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, _ = actionsClient.GetAcquirableJobs(context.Background(), runnerScaleSet.Id)
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
})
}

View File

@@ -0,0 +1,269 @@
package actions_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetMessage(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
runnerScaleSetMessage := &actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "rssType",
}
t.Run("Get Runner Scale Set Message", func(t *testing.T) {
want := runnerScaleSetMessage
response := []byte(`{"messageId":1,"messageType":"rssType"}`)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(response)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.GetMessage(context.Background(), s.URL, token, 0)
if err != nil {
t.Fatalf("GetMessage got unexepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetMessage mismatch (-want +got):\n%s", diff)
}
})
t.Run("Default retries on server error", func(t *testing.T) {
retryClient := retryablehttp.NewClient()
retryClient.RetryWaitMax = 1 * time.Nanosecond
retryClient.RetryMax = 1
actualRetry := 0
expectedRetry := retryClient.RetryMax + 1
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, _ = actionsClient.GetMessage(context.Background(), s.URL, token, 0)
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
})
t.Run("Custom retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryMax := 1
retryWaitMax := 1 * time.Nanosecond
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
_, _ = actionsClient.GetMessage(context.Background(), s.URL, token, 0)
expectedRetry := retryMax + 1
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) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.GetMessage(context.Background(), s.URL, token, 0)
if err == nil {
t.Fatalf("GetMessage did not get exepected error, ")
}
var expectedErr *actions.MessageQueueTokenExpiredError
require.True(t, errors.As(err, &expectedErr))
},
)
t.Run("Status code not found", func(t *testing.T) {
want := actions.ActionsError{
Message: "Request returned status: 404 Not Found",
StatusCode: 404,
}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.GetMessage(context.Background(), s.URL, token, 0)
if err == nil {
t.Fatalf("GetMessage did not get exepected error, ")
}
if diff := cmp.Diff(want.Error(), err.Error()); diff != "" {
t.Errorf("GetMessage mismatch (-want +got):\n%s", diff)
}
},
)
t.Run("Error when Content-Type is text/plain", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.GetMessage(context.Background(), s.URL, token, 0)
if err == nil {
t.Fatalf("GetMessage did not get exepected error,")
}
},
)
}
func TestDeleteMessage(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
runnerScaleSetMessage := &actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "rssType",
}
t.Run("Delete existing message", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
err := actionsClient.DeleteMessage(context.Background(), s.URL, token, runnerScaleSetMessage.MessageId)
if err != nil {
t.Fatalf("DeleteMessage got unexepected error, %v", err)
}
},
)
t.Run("Message token expired", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
err := actionsClient.DeleteMessage(context.Background(), s.URL, token, 0)
if err == nil {
t.Fatalf("DeleteMessage did not get exepected error, ")
}
var expectedErr *actions.MessageQueueTokenExpiredError
require.True(t, errors.As(err, &expectedErr))
},
)
t.Run("Error when Content-Type is text/plain", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
err := actionsClient.DeleteMessage(context.Background(), s.URL, token, runnerScaleSetMessage.MessageId)
if err == nil {
t.Fatalf("DeleteMessage did not get exepected error")
}
var expectedErr *actions.ActionsError
require.True(t, errors.As(err, &expectedErr))
},
)
t.Run("Default retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryClient := retryablehttp.NewClient()
retryMax := 1
retryClient.RetryWaitMax = time.Nanosecond
retryClient.RetryMax = retryMax
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_ = actionsClient.DeleteMessage(context.Background(), s.URL, token, runnerScaleSetMessage.MessageId)
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 := (*actions.RunnerScaleSetMessage)(nil)
rsl, err := json.Marshal(want)
if err != nil {
t.Fatalf("%v", err)
}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(rsl)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
err = actionsClient.DeleteMessage(context.Background(), s.URL, token, runnerScaleSetMessage.MessageId+1)
var expectedErr *actions.ActionsError
require.True(t, errors.As(err, &expectedErr))
},
)
}

View File

@@ -0,0 +1,244 @@
package actions_test
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/google/go-cmp/cmp"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
func TestCreateMessageSession(t *testing.T) {
t.Run("CreateMessageSession unmarshals correctly", func(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
owner := "foo"
runnerScaleSet := actions.RunnerScaleSet{
Id: 1,
Name: "ScaleSet",
CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
RunnerSetting: actions.RunnerSetting{},
}
want := &actions.RunnerScaleSetSession{
OwnerName: "foo",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
Name: "ScaleSet",
},
MessageQueueUrl: "http://fake.actions.github.com/123",
MessageQueueAccessToken: "fake.jwt.here",
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
resp := []byte(`{
"ownerName": "foo",
"runnerScaleSet": {
"id": 1,
"name": "ScaleSet"
},
"messageQueueUrl": "http://fake.actions.github.com/123",
"messageQueueAccessToken": "fake.jwt.here"
}`)
w.Write(resp)
}))
defer srv.Close()
retryMax := 1
retryWaitMax := 1 * time.Microsecond
actionsClient := actions.Client{
ActionsServiceURL: &srv.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
got, err := actionsClient.CreateMessageSession(context.Background(), runnerScaleSet.Id, owner)
if err != nil {
t.Fatalf("CreateMessageSession got unexpected error: %v", err)
}
if diff := cmp.Diff(got, want); diff != "" {
t.Fatalf("CreateMessageSession got unexpected diff: -want +got: %v", diff)
}
})
t.Run("CreateMessageSession unmarshals errors into ActionsError", func(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
owner := "foo"
runnerScaleSet := actions.RunnerScaleSet{
Id: 1,
Name: "ScaleSet",
CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
RunnerSetting: actions.RunnerSetting{},
}
want := &actions.ActionsError{
ExceptionName: "CSharpExceptionNameHere",
Message: "could not do something",
StatusCode: http.StatusBadRequest,
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest)
resp := []byte(`{"typeName": "CSharpExceptionNameHere","message": "could not do something"}`)
w.Write(resp)
}))
defer srv.Close()
retryMax := 1
retryWaitMax := 1 * time.Microsecond
actionsClient := actions.Client{
ActionsServiceURL: &srv.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
got, err := actionsClient.CreateMessageSession(context.Background(), runnerScaleSet.Id, owner)
if err == nil {
t.Fatalf("CreateMessageSession did not get expected error: %v", got)
}
errorTypeForComparison := &actions.ActionsError{}
if isActionsError := errors.As(err, &errorTypeForComparison); !isActionsError {
t.Fatalf("CreateMessageSession expected to be able to parse the error into ActionsError type: %v", err)
}
gotErr := err.(*actions.ActionsError)
if diff := cmp.Diff(want, gotErr); diff != "" {
t.Fatalf("CreateMessageSession got unexpected diff: -want +got: %v", diff)
}
})
t.Run("CreateMessageSession call is retried the correct amount of times", func(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
owner := "foo"
runnerScaleSet := actions.RunnerScaleSet{
Id: 1,
Name: "ScaleSet",
CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
RunnerSetting: actions.RunnerSetting{},
}
gotRetries := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
gotRetries++
}))
defer srv.Close()
retryMax := 3
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
wantRetries := retryMax + 1
actionsClient := actions.Client{
ActionsServiceURL: &srv.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
_, _ = actionsClient.CreateMessageSession(context.Background(), runnerScaleSet.Id, owner)
assert.Equalf(t, gotRetries, wantRetries, "CreateMessageSession got unexpected retry count: got=%v, want=%v", gotRetries, wantRetries)
})
}
func TestDeleteMessageSession(t *testing.T) {
t.Run("DeleteMessageSession call is retried the correct amount of times", func(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
runnerScaleSet := actions.RunnerScaleSet{
Id: 1,
Name: "ScaleSet",
CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
RunnerSetting: actions.RunnerSetting{},
}
gotRetries := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
gotRetries++
}))
defer srv.Close()
retryMax := 3
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
wantRetries := retryMax + 1
actionsClient := actions.Client{
ActionsServiceURL: &srv.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
sessionId := uuid.New()
_ = actionsClient.DeleteMessageSession(context.Background(), runnerScaleSet.Id, &sessionId)
assert.Equalf(t, gotRetries, wantRetries, "CreateMessageSession got unexpected retry count: got=%v, want=%v", gotRetries, wantRetries)
})
}
func TestRefreshMessageSession(t *testing.T) {
t.Run("RefreshMessageSession call is retried the correct amount of times", func(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
runnerScaleSet := actions.RunnerScaleSet{
Id: 1,
Name: "ScaleSet",
CreatedOn: time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC),
RunnerSetting: actions.RunnerSetting{},
}
gotRetries := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
gotRetries++
}))
defer srv.Close()
retryMax := 3
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
wantRetries := retryMax + 1
actionsClient := actions.Client{
ActionsServiceURL: &srv.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
sessionId := uuid.New()
_, _ = actionsClient.RefreshMessageSession(context.Background(), runnerScaleSet.Id, &sessionId)
assert.Equalf(t, gotRetries, wantRetries, "CreateMessageSession got unexpected retry count: got=%v, want=%v", gotRetries, wantRetries)
})
}

View File

@@ -0,0 +1,858 @@
package actions_test
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetRunnerScaleSet(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
scaleSetName := "ScaleSet"
runnerScaleSet := actions.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"}]}`)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(runnerScaleSetsResp)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.GetRunnerScaleSet(context.Background(), scaleSetName)
if err != nil {
t.Fatalf("CreateRunnerScaleSet got unexepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetRunnerScaleSet(%v) mismatch (-want +got):\n%s", scaleSetName, diff)
}
},
)
t.Run("GetRunnerScaleSet calls correct url", func(t *testing.T) {
runnerScaleSetsResp := []byte(`{"count":1,"value":[{"id":1,"name":"ScaleSet"}]}`)
url := url.URL{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(runnerScaleSetsResp)
url = *r.URL
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.GetRunnerScaleSet(context.Background(), scaleSetName)
if err != nil {
t.Fatalf("CreateRunnerScaleSet got unexepected error, %v", err)
}
u := url.String()
expectedUrl := fmt.Sprintf("/_apis/runtime/runnerscalesets?name=%s&api-version=6.0-preview", scaleSetName)
assert.Equal(t, expectedUrl, u)
},
)
t.Run("Status code not found", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.GetRunnerScaleSet(context.Background(), scaleSetName)
if err == nil {
t.Fatalf("GetRunnerScaleSet did not get exepected error, ")
}
},
)
t.Run("Error when Content-Type is text/plain", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.GetRunnerScaleSet(context.Background(), scaleSetName)
if err == nil {
t.Fatalf("GetRunnerScaleSet did not get exepected error,")
}
},
)
t.Run("Default retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryClient := retryablehttp.NewClient()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
retryClient.RetryWaitMax = retryWaitMax
retryClient.RetryMax = retryMax
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, _ = actionsClient.GetRunnerScaleSet(context.Background(), scaleSetName)
expectedRetry := retryMax + 1
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
},
)
t.Run("Custom retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
_, _ = actionsClient.GetRunnerScaleSet(context.Background(), scaleSetName)
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 := (*actions.RunnerScaleSet)(nil)
runnerScaleSetsResp := []byte(`{"count":0,"value":[{"id":1,"name":"ScaleSet"}]}`)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(runnerScaleSetsResp)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, _ := actionsClient.GetRunnerScaleSet(context.Background(), scaleSetName)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetRunnerScaleSet(%v) mismatch (-want +got):\n%s", scaleSetName, diff)
}
},
)
t.Run("Multiple runner scale sets found", func(t *testing.T) {
wantErr := fmt.Errorf("multiple runner scale sets found with name %s", scaleSetName)
runnerScaleSetsResp := []byte(`{"count":2,"value":[{"id":1,"name":"ScaleSet"}]}`)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(runnerScaleSetsResp)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.GetRunnerScaleSet(context.Background(), scaleSetName)
if err == nil {
t.Fatalf("GetRunnerScaleSet did not get exepected error, %v", wantErr)
}
if diff := cmp.Diff(wantErr.Error(), err.Error()); diff != "" {
t.Errorf("GetRunnerScaleSet(%v) mismatch (-want +got):\n%s", scaleSetName, diff)
}
},
)
}
func TestGetRunnerScaleSetById(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)
runnerScaleSet := actions.RunnerScaleSet{Id: 1, Name: "ScaleSet", CreatedOn: scaleSetCreationDateTime, RunnerSetting: actions.RunnerSetting{}}
t.Run("Get existing scale set by Id", func(t *testing.T) {
want := &runnerScaleSet
rsl, err := json.Marshal(want)
if err != nil {
t.Fatalf("%v", err)
}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(rsl)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.GetRunnerScaleSetById(context.Background(), runnerScaleSet.Id)
if err != nil {
t.Fatalf("GetRunnerScaleSetById got unexepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetRunnerScaleSetById(%d) mismatch (-want +got):\n%s", runnerScaleSet.Id, diff)
}
},
)
t.Run("GetRunnerScaleSetById calls correct url", func(t *testing.T) {
rsl, err := json.Marshal(&runnerScaleSet)
if err != nil {
t.Fatalf("%v", err)
}
url := url.URL{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(rsl)
url = *r.URL
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err = actionsClient.GetRunnerScaleSetById(context.Background(), runnerScaleSet.Id)
if err != nil {
t.Fatalf("GetRunnerScaleSetById got unexepected error, %v", err)
}
u := url.String()
expectedUrl := fmt.Sprintf("/_apis/runtime/runnerscalesets/%d?api-version=6.0-preview", runnerScaleSet.Id)
assert.Equal(t, expectedUrl, u)
},
)
t.Run("Status code not found", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.GetRunnerScaleSetById(context.Background(), runnerScaleSet.Id)
if err == nil {
t.Fatalf("GetRunnerScaleSetById did not get exepected error, ")
}
},
)
t.Run("Error when Content-Type is text/plain", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.GetRunnerScaleSetById(context.Background(), runnerScaleSet.Id)
if err == nil {
t.Fatalf("GetRunnerScaleSetById did not get exepected error,")
}
},
)
t.Run("Default retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryClient := retryablehttp.NewClient()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
retryClient.RetryWaitMax = retryWaitMax
retryClient.RetryMax = retryMax
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, _ = actionsClient.GetRunnerScaleSetById(context.Background(), runnerScaleSet.Id)
expectedRetry := retryMax + 1
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
},
)
t.Run("Custom retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
_, _ = actionsClient.GetRunnerScaleSetById(context.Background(), runnerScaleSet.Id)
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 := (*actions.RunnerScaleSet)(nil)
rsl, err := json.Marshal(want)
if err != nil {
t.Fatalf("%v", err)
}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(rsl)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, _ := actionsClient.GetRunnerScaleSetById(context.Background(), runnerScaleSet.Id)
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetRunnerScaleSetById(%v) mismatch (-want +got):\n%s", runnerScaleSet.Id, diff)
}
},
)
}
func TestCreateRunnerScaleSet(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)
runnerScaleSet := actions.RunnerScaleSet{Id: 1, Name: "ScaleSet", CreatedOn: scaleSetCreationDateTime, RunnerSetting: actions.RunnerSetting{}}
t.Run("Create runner scale set", func(t *testing.T) {
want := &runnerScaleSet
rsl, err := json.Marshal(want)
if err != nil {
t.Fatalf("%v", err)
}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(rsl)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.CreateRunnerScaleSet(context.Background(), &runnerScaleSet)
if err != nil {
t.Fatalf("CreateRunnerScaleSet got exepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("CreateRunnerScaleSet(%d) mismatch (-want +got):\n%s", runnerScaleSet.Id, diff)
}
},
)
t.Run("CreateRunnerScaleSet calls correct url", func(t *testing.T) {
rsl, err := json.Marshal(&runnerScaleSet)
if err != nil {
t.Fatalf("%v", err)
}
url := url.URL{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(rsl)
url = *r.URL
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err = actionsClient.CreateRunnerScaleSet(context.Background(), &runnerScaleSet)
if err != nil {
t.Fatalf("CreateRunnerScaleSet got unexepected error, %v", err)
}
u := url.String()
expectedUrl := "/_apis/runtime/runnerscalesets?api-version=6.0-preview"
assert.Equal(t, expectedUrl, u)
},
)
t.Run("Error when Content-Type is text/plain", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.CreateRunnerScaleSet(context.Background(), &runnerScaleSet)
if err == nil {
t.Fatalf("CreateRunnerScaleSet did not get exepected error, %v", &actions.ActionsError{})
}
var expectedErr *actions.ActionsError
require.True(t, errors.As(err, &expectedErr))
},
)
t.Run("Default retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryClient := retryablehttp.NewClient()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
retryClient.RetryMax = retryMax
retryClient.RetryWaitMax = retryWaitMax
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, _ = actionsClient.CreateRunnerScaleSet(context.Background(), &runnerScaleSet)
expectedRetry := retryMax + 1
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
},
)
t.Run("Custom retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
_, _ = actionsClient.CreateRunnerScaleSet(context.Background(), &runnerScaleSet)
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) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)
runnerScaleSet := actions.RunnerScaleSet{Id: 1, Name: "ScaleSet", CreatedOn: scaleSetCreationDateTime, RunnerSetting: actions.RunnerSetting{}}
t.Run("Update existing scale set", func(t *testing.T) {
want := &runnerScaleSet
rsl, err := json.Marshal(want)
if err != nil {
t.Fatalf("%v", err)
}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(rsl)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.UpdateRunnerScaleSet(context.Background(), runnerScaleSet.Id, want)
if err != nil {
t.Fatalf("UpdateRunnerScaleSet got exepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("UpdateRunnerScaleSet(%d) mismatch (-want +got):\n%s", runnerScaleSet.Id, diff)
}
},
)
t.Run("UpdateRunnerScaleSet calls correct url", func(t *testing.T) {
rsl, err := json.Marshal(&runnerScaleSet)
if err != nil {
t.Fatalf("%v", err)
}
url := url.URL{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(rsl)
url = *r.URL
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err = actionsClient.UpdateRunnerScaleSet(context.Background(), runnerScaleSet.Id, &runnerScaleSet)
if err != nil {
t.Fatalf("UpdateRunnerScaleSet got unexepected error, %v", err)
}
u := url.String()
expectedUrl := fmt.Sprintf("/_apis/runtime/runnerscalesets/%d?api-version=6.0-preview", runnerScaleSet.Id)
assert.Equal(t, expectedUrl, u)
},
)
t.Run("Status code not found", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.UpdateRunnerScaleSet(context.Background(), runnerScaleSet.Id, &runnerScaleSet)
if err == nil {
t.Fatalf("UpdateRunnerScaleSet did not get exepected error,")
}
var expectedErr *actions.ActionsError
require.True(t, errors.As(err, &expectedErr))
},
)
t.Run("Error when Content-Type is text/plain", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, err := actionsClient.UpdateRunnerScaleSet(context.Background(), runnerScaleSet.Id, &runnerScaleSet)
if err == nil {
t.Fatalf("UpdateRunnerScaleSet did not get exepected error")
}
var expectedErr *actions.ActionsError
require.True(t, errors.As(err, &expectedErr))
},
)
t.Run("Default retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryClient := retryablehttp.NewClient()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
retryClient.RetryWaitMax = retryWaitMax
retryClient.RetryMax = retryMax
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, _ = actionsClient.UpdateRunnerScaleSet(context.Background(), runnerScaleSet.Id, &runnerScaleSet)
expectedRetry := retryMax + 1
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
},
)
t.Run("Custom retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
_, _ = actionsClient.UpdateRunnerScaleSet(context.Background(), runnerScaleSet.Id, &runnerScaleSet)
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 := (*actions.RunnerScaleSet)(nil)
rsl, err := json.Marshal(want)
if err != nil {
t.Fatalf("%v", err)
}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(rsl)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.UpdateRunnerScaleSet(context.Background(), runnerScaleSet.Id, &runnerScaleSet)
if err != nil {
t.Fatalf("UpdateRunnerScaleSet got unexepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("UpdateRunnerScaleSet(%v) mismatch (-want +got):\n%s", runnerScaleSet.Id, diff)
}
},
)
}
func TestDeleteRunnerScaleSet(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
scaleSetCreationDateTime := time.Date(1, time.January, 1, 0, 0, 0, 0, time.UTC)
runnerScaleSet := actions.RunnerScaleSet{Id: 1, Name: "ScaleSet", CreatedOn: scaleSetCreationDateTime, RunnerSetting: actions.RunnerSetting{}}
t.Run("Delete existing scale set", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
err := actionsClient.DeleteRunnerScaleSet(context.Background(), runnerScaleSet.Id)
if err != nil {
t.Fatalf("DeleteRunnerScaleSet got unexepected error, %v", err)
}
},
)
t.Run("DeleteRunnerScaleSet calls correct url", func(t *testing.T) {
url := url.URL{}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
url = *r.URL
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
err := actionsClient.DeleteRunnerScaleSet(context.Background(), runnerScaleSet.Id)
if err != nil {
t.Fatalf("DeleteRunnerScaleSet got unexepected error, %v", err)
}
u := url.String()
expectedUrl := fmt.Sprintf("/_apis/runtime/runnerscalesets/%d?api-version=6.0-preview", runnerScaleSet.Id)
assert.Equal(t, expectedUrl, u)
},
)
t.Run("Status code not found", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
err := actionsClient.DeleteRunnerScaleSet(context.Background(), runnerScaleSet.Id)
if err == nil {
t.Fatalf("DeleteRunnerScaleSet did not get exepected error, ")
}
var expectedErr *actions.ActionsError
require.True(t, errors.As(err, &expectedErr))
},
)
t.Run("Error when Content-Type is text/plain", func(t *testing.T) {
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadRequest)
w.Header().Set("Content-Type", "text/plain")
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
err := actionsClient.DeleteRunnerScaleSet(context.Background(), runnerScaleSet.Id)
if err == nil {
t.Fatalf("DeleteRunnerScaleSet did not get exepected error")
}
var expectedErr *actions.ActionsError
require.True(t, errors.As(err, &expectedErr))
},
)
t.Run("Default retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryClient := retryablehttp.NewClient()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
retryClient.RetryWaitMax = retryWaitMax
retryClient.RetryMax = retryMax
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_ = actionsClient.DeleteRunnerScaleSet(context.Background(), runnerScaleSet.Id)
expectedRetry := retryMax + 1
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
},
)
t.Run("Custom retries on server error", func(t *testing.T) {
actualRetry := 0
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
retryMax := 1
retryWaitMax, err := time.ParseDuration("1µs")
if err != nil {
t.Fatalf("%v", err)
}
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
RetryMax: &retryMax,
RetryWaitMax: &retryWaitMax,
}
_ = actionsClient.DeleteRunnerScaleSet(context.Background(), runnerScaleSet.Id)
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 := (*actions.RunnerScaleSet)(nil)
rsl, err := json.Marshal(want)
if err != nil {
t.Fatalf("%v", err)
}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Write(rsl)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
err = actionsClient.DeleteRunnerScaleSet(context.Background(), runnerScaleSet.Id)
var expectedErr *actions.ActionsError
require.True(t, errors.As(err, &expectedErr))
},
)
}

View File

@@ -0,0 +1,219 @@
package actions_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/assert"
)
var tokenExpireAt = time.Now().Add(10 * time.Minute)
func TestGetRunner(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
t.Run("Get Runner", func(t *testing.T) {
name := "Get Runner"
var runnerID int64 = 1
want := &actions.RunnerReference{
Id: int(runnerID),
Name: "self-hosted-ubuntu",
}
response := []byte(`{"id": 1, "name": "self-hosted-ubuntu"}`)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(response)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.GetRunner(context.Background(), runnerID)
if err != nil {
t.Fatalf("GetRunner got unexepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetRunner(%v) mismatch (-want +got):\n%s", name, diff)
}
})
t.Run("Default retries on server error", func(t *testing.T) {
var runnerID int64 = 1
retryClient := retryablehttp.NewClient()
retryClient.RetryWaitMax = 1 * time.Millisecond
retryClient.RetryMax = 1
actualRetry := 0
expectedRetry := retryClient.RetryMax + 1
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, _ = actionsClient.GetRunner(context.Background(), runnerID)
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
})
}
func TestGetRunnerByName(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
t.Run("Get Runner by Name", func(t *testing.T) {
var runnerID int64 = 1
var runnerName string = "self-hosted-ubuntu"
want := &actions.RunnerReference{
Id: int(runnerID),
Name: runnerName,
}
response := []byte(`{"count": 1, "value": [{"id": 1, "name": "self-hosted-ubuntu"}]}`)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(response)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.GetRunnerByName(context.Background(), runnerName)
if err != nil {
t.Fatalf("GetRunnerByName got unexepected error, %v", err)
}
if diff := cmp.Diff(want, got); diff != "" {
t.Errorf("GetRunnerByName(%v) mismatch (-want +got):\n%s", runnerName, diff)
}
})
t.Run("Get Runner by name with not exist runner", func(t *testing.T) {
var runnerName string = "self-hosted-ubuntu"
response := []byte(`{"count": 0, "value": []}`)
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write(response)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
got, err := actionsClient.GetRunnerByName(context.Background(), runnerName)
if err != nil {
t.Fatalf("GetRunnerByName got unexepected error, %v", err)
}
if diff := cmp.Diff((*actions.RunnerReference)(nil), got); diff != "" {
t.Errorf("GetRunnerByName(%v) mismatch (-want +got):\n%s", runnerName, diff)
}
})
t.Run("Default retries on server error", func(t *testing.T) {
var runnerName string = "self-hosted-ubuntu"
retryClient := retryablehttp.NewClient()
retryClient.RetryWaitMax = 1 * time.Millisecond
retryClient.RetryMax = 1
actualRetry := 0
expectedRetry := retryClient.RetryMax + 1
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_, _ = actionsClient.GetRunnerByName(context.Background(), runnerName)
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
})
}
func TestDeleteRunner(t *testing.T) {
token := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
t.Run("Delete Runner", func(t *testing.T) {
var runnerID int64 = 1
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer s.Close()
actionsClient := actions.Client{
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
if err := actionsClient.RemoveRunner(context.Background(), runnerID); err != nil {
t.Fatalf("RemoveRunner got unexepected error, %v", err)
}
})
t.Run("Default retries on server error", func(t *testing.T) {
var runnerID int64 = 1
retryClient := retryablehttp.NewClient()
retryClient.RetryWaitMax = 1 * time.Millisecond
retryClient.RetryMax = 1
actualRetry := 0
expectedRetry := retryClient.RetryMax + 1
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusServiceUnavailable)
actualRetry++
}))
defer s.Close()
httpClient := retryClient.StandardClient()
actionsClient := actions.Client{
Client: httpClient,
ActionsServiceURL: &s.URL,
ActionsServiceAdminToken: &token,
ActionsServiceAdminTokenExpiresAt: &tokenExpireAt,
}
_ = actionsClient.RemoveRunner(context.Background(), runnerID)
assert.Equalf(t, actualRetry, expectedRetry, "A retry was expected after the first request but got: %v", actualRetry)
})
}

71
github/actions/errors.go Normal file
View File

@@ -0,0 +1,71 @@
package actions
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
)
type ActionsError struct {
ExceptionName string `json:"typeName,omitempty"`
Message string `json:"message,omitempty"`
StatusCode int
}
func (e *ActionsError) Error() string {
return fmt.Sprintf("%v - had issue communicating with Actions backend: %v", e.StatusCode, e.Message)
}
func ParseActionsErrorFromResponse(response *http.Response) error {
if response.ContentLength == 0 {
message := "Request returned status: " + response.Status
return &ActionsError{
ExceptionName: "unknown",
Message: message,
StatusCode: response.StatusCode,
}
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return err
}
body = trimByteOrderMark(body)
contentType, ok := response.Header["Content-Type"]
if ok && len(contentType) > 0 && strings.Contains(contentType[0], "text/plain") {
message := string(body)
statusCode := response.StatusCode
return &ActionsError{
Message: message,
StatusCode: statusCode,
}
}
actionsError := &ActionsError{StatusCode: response.StatusCode}
if err := json.Unmarshal(body, &actionsError); err != nil {
return err
}
return actionsError
}
type MessageQueueTokenExpiredError struct {
msg string
}
func (e *MessageQueueTokenExpiredError) Error() string {
return e.msg
}
type HttpClientSideError struct {
msg string
Code int
}
func (e *HttpClientSideError) Error() string {
return e.msg
}

View File

@@ -0,0 +1,235 @@
package fake
import (
"context"
"time"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/google/uuid"
)
type Option func(*FakeClient)
func WithGetRunnerScaleSetResult(scaleSet *actions.RunnerScaleSet, err error) Option {
return func(f *FakeClient) {
f.getRunnerScaleSetResult.RunnerScaleSet = scaleSet
f.getRunnerScaleSetResult.err = err
}
}
func WithGetRunner(runner *actions.RunnerReference, err error) Option {
return func(f *FakeClient) {
f.getRunnerResult.RunnerReference = runner
f.getRunnerResult.err = err
}
}
var defaultRunnerScaleSet = &actions.RunnerScaleSet{
Id: 1,
Name: "testset",
RunnerGroupId: 1,
RunnerGroupName: "testgroup",
Labels: []actions.Label{{Type: "test", Name: "test"}},
RunnerSetting: actions.RunnerSetting{},
CreatedOn: time.Now(),
RunnerJitConfigUrl: "test.test.test",
Statistics: nil,
}
var defaultRunnerGroup = &actions.RunnerGroup{
ID: 1,
Name: "testgroup",
Size: 1,
IsDefault: true,
}
var sessionID = uuid.New()
var defaultRunnerScaleSetSession = &actions.RunnerScaleSetSession{
SessionId: &sessionID,
OwnerName: "testowner",
RunnerScaleSet: defaultRunnerScaleSet,
MessageQueueUrl: "https://test.url/path",
MessageQueueAccessToken: "faketoken",
Statistics: nil,
}
var defaultAcquirableJob = &actions.AcquirableJob{
AcquireJobUrl: "https://test.url",
MessageType: "",
RunnerRequestId: 1,
RepositoryName: "testrepo",
OwnerName: "testowner",
JobWorkflowRef: "workflowref",
EventName: "testevent",
RequestLabels: []string{"test"},
}
var defaultAcquirableJobList = &actions.AcquirableJobList{
Count: 1,
Jobs: []actions.AcquirableJob{*defaultAcquirableJob},
}
var defaultRunnerReference = &actions.RunnerReference{
Id: 1,
Name: "testrunner",
RunnerScaleSetId: 1,
}
var defaultRunnerScaleSetMessage = &actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "test",
Body: "{}",
Statistics: nil,
}
var defaultRunnerScaleSetJitRunnerConfig = &actions.RunnerScaleSetJitRunnerConfig{
Runner: defaultRunnerReference,
EncodedJITConfig: "test",
}
// FakeClient implements actions service
type FakeClient struct {
getRunnerScaleSetResult struct {
*actions.RunnerScaleSet
err error
}
getRunnerScaleSetByIdResult struct {
*actions.RunnerScaleSet
err error
}
getRunnerGroupByNameResult struct {
*actions.RunnerGroup
err error
}
createRunnerScaleSetResult struct {
*actions.RunnerScaleSet
err error
}
createMessageSessionResult struct {
*actions.RunnerScaleSetSession
err error
}
deleteMessageSessionResult struct {
err error
}
refreshMessageSessionResult struct {
*actions.RunnerScaleSetSession
err error
}
acquireJobsResult struct {
ids []int64
err error
}
getAcquirableJobsResult struct {
*actions.AcquirableJobList
err error
}
getMessageResult struct {
*actions.RunnerScaleSetMessage
err error
}
deleteMessageResult struct {
err error
}
generateJitRunnerConfigResult struct {
*actions.RunnerScaleSetJitRunnerConfig
err error
}
getRunnerResult struct {
*actions.RunnerReference
err error
}
getRunnerByNameResult struct {
*actions.RunnerReference
err error
}
removeRunnerResult struct {
err error
}
}
func NewFakeClient(options ...Option) actions.ActionsService {
f := &FakeClient{}
f.applyDefaults()
for _, opt := range options {
opt(f)
}
return f
}
func (f *FakeClient) applyDefaults() {
f.getRunnerScaleSetResult.RunnerScaleSet = defaultRunnerScaleSet
f.getRunnerScaleSetByIdResult.RunnerScaleSet = defaultRunnerScaleSet
f.getRunnerGroupByNameResult.RunnerGroup = defaultRunnerGroup
f.createRunnerScaleSetResult.RunnerScaleSet = defaultRunnerScaleSet
f.createMessageSessionResult.RunnerScaleSetSession = defaultRunnerScaleSetSession
f.refreshMessageSessionResult.RunnerScaleSetSession = defaultRunnerScaleSetSession
f.acquireJobsResult.ids = []int64{1}
f.getAcquirableJobsResult.AcquirableJobList = defaultAcquirableJobList
f.getMessageResult.RunnerScaleSetMessage = defaultRunnerScaleSetMessage
f.generateJitRunnerConfigResult.RunnerScaleSetJitRunnerConfig = defaultRunnerScaleSetJitRunnerConfig
f.getRunnerResult.RunnerReference = defaultRunnerReference
f.getRunnerByNameResult.RunnerReference = defaultRunnerReference
}
func (f *FakeClient) GetRunnerScaleSet(ctx context.Context, runnerScaleSetName string) (*actions.RunnerScaleSet, error) {
return f.getRunnerScaleSetResult.RunnerScaleSet, f.getRunnerScaleSetResult.err
}
func (f *FakeClient) GetRunnerScaleSetById(ctx context.Context, runnerScaleSetId int) (*actions.RunnerScaleSet, error) {
return f.getRunnerScaleSetByIdResult.RunnerScaleSet, f.getRunnerScaleSetResult.err
}
func (f *FakeClient) GetRunnerGroupByName(ctx context.Context, runnerGroup string) (*actions.RunnerGroup, error) {
return f.getRunnerGroupByNameResult.RunnerGroup, f.getRunnerGroupByNameResult.err
}
func (f *FakeClient) CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *actions.RunnerScaleSet) (*actions.RunnerScaleSet, error) {
return f.createRunnerScaleSetResult.RunnerScaleSet, f.createRunnerScaleSetResult.err
}
func (f *FakeClient) CreateMessageSession(ctx context.Context, runnerScaleSetId int, owner string) (*actions.RunnerScaleSetSession, error) {
return f.createMessageSessionResult.RunnerScaleSetSession, f.createMessageSessionResult.err
}
func (f *FakeClient) DeleteMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) error {
return f.deleteMessageSessionResult.err
}
func (f *FakeClient) RefreshMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) (*actions.RunnerScaleSetSession, error) {
return f.refreshMessageSessionResult.RunnerScaleSetSession, f.refreshMessageSessionResult.err
}
func (f *FakeClient) AcquireJobs(ctx context.Context, runnerScaleSetId int, messageQueueAccessToken string, requestIds []int64) ([]int64, error) {
return f.acquireJobsResult.ids, f.acquireJobsResult.err
}
func (f *FakeClient) GetAcquirableJobs(ctx context.Context, runnerScaleSetId int) (*actions.AcquirableJobList, error) {
return f.getAcquirableJobsResult.AcquirableJobList, f.getAcquirableJobsResult.err
}
func (f *FakeClient) GetMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, lastMessageId int64) (*actions.RunnerScaleSetMessage, error) {
return f.getMessageResult.RunnerScaleSetMessage, f.getMessageResult.err
}
func (f *FakeClient) DeleteMessage(ctx context.Context, messageQueueUrl, messageQueueAccessToken string, messageId int64) error {
return f.deleteMessageResult.err
}
func (f *FakeClient) GenerateJitRunnerConfig(ctx context.Context, jitRunnerSetting *actions.RunnerScaleSetJitRunnerSetting, scaleSetId int) (*actions.RunnerScaleSetJitRunnerConfig, error) {
return f.generateJitRunnerConfigResult.RunnerScaleSetJitRunnerConfig, f.generateJitRunnerConfigResult.err
}
func (f *FakeClient) GetRunner(ctx context.Context, runnerId int64) (*actions.RunnerReference, error) {
return f.getRunnerResult.RunnerReference, f.getRunnerResult.err
}
func (f *FakeClient) GetRunnerByName(ctx context.Context, runnerName string) (*actions.RunnerReference, error) {
return f.getRunnerByNameResult.RunnerReference, f.getRunnerByNameResult.err
}
func (f *FakeClient) RemoveRunner(ctx context.Context, runnerId int64) error {
return f.removeRunnerResult.err
}

View File

@@ -0,0 +1,43 @@
package fake
import (
"context"
"github.com/actions/actions-runner-controller/github/actions"
)
type MultiClientOption func(*fakeMultiClient)
func WithDefaultClient(client actions.ActionsService, err error) MultiClientOption {
return func(f *fakeMultiClient) {
f.defaultClient = client
f.defaultErr = err
}
}
type fakeMultiClient struct {
defaultClient actions.ActionsService
defaultErr error
}
func NewMultiClient(opts ...MultiClientOption) actions.MultiClient {
f := &fakeMultiClient{}
for _, opt := range opts {
opt(f)
}
if f.defaultClient == nil {
f.defaultClient = NewFakeClient()
}
return f
}
func (f *fakeMultiClient) GetClientFor(ctx context.Context, githubConfigURL string, creds actions.ActionsAuth, namespace string) (actions.ActionsService, error) {
return f.defaultClient, f.defaultErr
}
func (f *fakeMultiClient) GetClientFromSecret(ctx context.Context, githubConfigURL, namespace string, secretData actions.KubernetesSecretData) (actions.ActionsService, error) {
return f.defaultClient, f.defaultErr
}

View File

@@ -0,0 +1,348 @@
// Code generated by mockery v2.16.0. DO NOT EDIT.
package actions
import (
context "context"
uuid "github.com/google/uuid"
mock "github.com/stretchr/testify/mock"
)
// MockActionsService is an autogenerated mock type for the ActionsService type
type MockActionsService struct {
mock.Mock
}
// AcquireJobs provides a mock function with given fields: ctx, runnerScaleSetId, messageQueueAccessToken, requestIds
func (_m *MockActionsService) AcquireJobs(ctx context.Context, runnerScaleSetId int, messageQueueAccessToken string, requestIds []int64) ([]int64, error) {
ret := _m.Called(ctx, runnerScaleSetId, messageQueueAccessToken, requestIds)
var r0 []int64
if rf, ok := ret.Get(0).(func(context.Context, int, string, []int64) []int64); ok {
r0 = rf(ctx, runnerScaleSetId, messageQueueAccessToken, requestIds)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]int64)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int, string, []int64) error); ok {
r1 = rf(ctx, runnerScaleSetId, messageQueueAccessToken, requestIds)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CreateMessageSession provides a mock function with given fields: ctx, runnerScaleSetId, owner
func (_m *MockActionsService) CreateMessageSession(ctx context.Context, runnerScaleSetId int, owner string) (*RunnerScaleSetSession, error) {
ret := _m.Called(ctx, runnerScaleSetId, owner)
var r0 *RunnerScaleSetSession
if rf, ok := ret.Get(0).(func(context.Context, int, string) *RunnerScaleSetSession); ok {
r0 = rf(ctx, runnerScaleSetId, owner)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerScaleSetSession)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int, string) error); ok {
r1 = rf(ctx, runnerScaleSetId, owner)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// CreateRunnerScaleSet provides a mock function with given fields: ctx, runnerScaleSet
func (_m *MockActionsService) CreateRunnerScaleSet(ctx context.Context, runnerScaleSet *RunnerScaleSet) (*RunnerScaleSet, error) {
ret := _m.Called(ctx, runnerScaleSet)
var r0 *RunnerScaleSet
if rf, ok := ret.Get(0).(func(context.Context, *RunnerScaleSet) *RunnerScaleSet); ok {
r0 = rf(ctx, runnerScaleSet)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerScaleSet)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, *RunnerScaleSet) error); ok {
r1 = rf(ctx, runnerScaleSet)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// DeleteMessage provides a mock function with given fields: ctx, messageQueueUrl, messageQueueAccessToken, messageId
func (_m *MockActionsService) DeleteMessage(ctx context.Context, messageQueueUrl string, messageQueueAccessToken string, messageId int64) error {
ret := _m.Called(ctx, messageQueueUrl, messageQueueAccessToken, messageId)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) error); ok {
r0 = rf(ctx, messageQueueUrl, messageQueueAccessToken, messageId)
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteMessageSession provides a mock function with given fields: ctx, runnerScaleSetId, sessionId
func (_m *MockActionsService) DeleteMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) error {
ret := _m.Called(ctx, runnerScaleSetId, sessionId)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int, *uuid.UUID) error); ok {
r0 = rf(ctx, runnerScaleSetId, sessionId)
} else {
r0 = ret.Error(0)
}
return r0
}
// GenerateJitRunnerConfig provides a mock function with given fields: ctx, jitRunnerSetting, scaleSetId
func (_m *MockActionsService) GenerateJitRunnerConfig(ctx context.Context, jitRunnerSetting *RunnerScaleSetJitRunnerSetting, scaleSetId int) (*RunnerScaleSetJitRunnerConfig, error) {
ret := _m.Called(ctx, jitRunnerSetting, scaleSetId)
var r0 *RunnerScaleSetJitRunnerConfig
if rf, ok := ret.Get(0).(func(context.Context, *RunnerScaleSetJitRunnerSetting, int) *RunnerScaleSetJitRunnerConfig); ok {
r0 = rf(ctx, jitRunnerSetting, scaleSetId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerScaleSetJitRunnerConfig)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, *RunnerScaleSetJitRunnerSetting, int) error); ok {
r1 = rf(ctx, jitRunnerSetting, scaleSetId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetAcquirableJobs provides a mock function with given fields: ctx, runnerScaleSetId
func (_m *MockActionsService) GetAcquirableJobs(ctx context.Context, runnerScaleSetId int) (*AcquirableJobList, error) {
ret := _m.Called(ctx, runnerScaleSetId)
var r0 *AcquirableJobList
if rf, ok := ret.Get(0).(func(context.Context, int) *AcquirableJobList); ok {
r0 = rf(ctx, runnerScaleSetId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*AcquirableJobList)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, runnerScaleSetId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMessage provides a mock function with given fields: ctx, messageQueueUrl, messageQueueAccessToken, lastMessageId
func (_m *MockActionsService) GetMessage(ctx context.Context, messageQueueUrl string, messageQueueAccessToken string, lastMessageId int64) (*RunnerScaleSetMessage, error) {
ret := _m.Called(ctx, messageQueueUrl, messageQueueAccessToken, lastMessageId)
var r0 *RunnerScaleSetMessage
if rf, ok := ret.Get(0).(func(context.Context, string, string, int64) *RunnerScaleSetMessage); ok {
r0 = rf(ctx, messageQueueUrl, messageQueueAccessToken, lastMessageId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerScaleSetMessage)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string, string, int64) error); ok {
r1 = rf(ctx, messageQueueUrl, messageQueueAccessToken, lastMessageId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRunner provides a mock function with given fields: ctx, runnerId
func (_m *MockActionsService) GetRunner(ctx context.Context, runnerId int64) (*RunnerReference, error) {
ret := _m.Called(ctx, runnerId)
var r0 *RunnerReference
if rf, ok := ret.Get(0).(func(context.Context, int64) *RunnerReference); ok {
r0 = rf(ctx, runnerId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerReference)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
r1 = rf(ctx, runnerId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRunnerByName provides a mock function with given fields: ctx, runnerName
func (_m *MockActionsService) GetRunnerByName(ctx context.Context, runnerName string) (*RunnerReference, error) {
ret := _m.Called(ctx, runnerName)
var r0 *RunnerReference
if rf, ok := ret.Get(0).(func(context.Context, string) *RunnerReference); ok {
r0 = rf(ctx, runnerName)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerReference)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, runnerName)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRunnerGroupByName provides a mock function with given fields: ctx, runnerGroup
func (_m *MockActionsService) GetRunnerGroupByName(ctx context.Context, runnerGroup string) (*RunnerGroup, error) {
ret := _m.Called(ctx, runnerGroup)
var r0 *RunnerGroup
if rf, ok := ret.Get(0).(func(context.Context, string) *RunnerGroup); ok {
r0 = rf(ctx, runnerGroup)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerGroup)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, runnerGroup)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRunnerScaleSet provides a mock function with given fields: ctx, runnerScaleSetName
func (_m *MockActionsService) GetRunnerScaleSet(ctx context.Context, runnerScaleSetName string) (*RunnerScaleSet, error) {
ret := _m.Called(ctx, runnerScaleSetName)
var r0 *RunnerScaleSet
if rf, ok := ret.Get(0).(func(context.Context, string) *RunnerScaleSet); ok {
r0 = rf(ctx, runnerScaleSetName)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerScaleSet)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, runnerScaleSetName)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetRunnerScaleSetById provides a mock function with given fields: ctx, runnerScaleSetId
func (_m *MockActionsService) GetRunnerScaleSetById(ctx context.Context, runnerScaleSetId int) (*RunnerScaleSet, error) {
ret := _m.Called(ctx, runnerScaleSetId)
var r0 *RunnerScaleSet
if rf, ok := ret.Get(0).(func(context.Context, int) *RunnerScaleSet); ok {
r0 = rf(ctx, runnerScaleSetId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerScaleSet)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, runnerScaleSetId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RefreshMessageSession provides a mock function with given fields: ctx, runnerScaleSetId, sessionId
func (_m *MockActionsService) RefreshMessageSession(ctx context.Context, runnerScaleSetId int, sessionId *uuid.UUID) (*RunnerScaleSetSession, error) {
ret := _m.Called(ctx, runnerScaleSetId, sessionId)
var r0 *RunnerScaleSetSession
if rf, ok := ret.Get(0).(func(context.Context, int, *uuid.UUID) *RunnerScaleSetSession); ok {
r0 = rf(ctx, runnerScaleSetId, sessionId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerScaleSetSession)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int, *uuid.UUID) error); ok {
r1 = rf(ctx, runnerScaleSetId, sessionId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// RemoveRunner provides a mock function with given fields: ctx, runnerId
func (_m *MockActionsService) RemoveRunner(ctx context.Context, runnerId int64) error {
ret := _m.Called(ctx, runnerId)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
r0 = rf(ctx, runnerId)
} else {
r0 = ret.Error(0)
}
return r0
}
type mockConstructorTestingTNewMockActionsService interface {
mock.TestingT
Cleanup(func())
}
// NewMockActionsService creates a new instance of MockActionsService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockActionsService(t mockConstructorTestingTNewMockActionsService) *MockActionsService {
mock := &MockActionsService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,103 @@
// Code generated by mockery v2.16.0. DO NOT EDIT.
package actions
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockSessionService is an autogenerated mock type for the SessionService type
type MockSessionService struct {
mock.Mock
}
// AcquireJobs provides a mock function with given fields: ctx, requestIds
func (_m *MockSessionService) AcquireJobs(ctx context.Context, requestIds []int64) ([]int64, error) {
ret := _m.Called(ctx, requestIds)
var r0 []int64
if rf, ok := ret.Get(0).(func(context.Context, []int64) []int64); ok {
r0 = rf(ctx, requestIds)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]int64)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []int64) error); ok {
r1 = rf(ctx, requestIds)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Close provides a mock function with given fields:
func (_m *MockSessionService) Close() error {
ret := _m.Called()
var r0 error
if rf, ok := ret.Get(0).(func() error); ok {
r0 = rf()
} else {
r0 = ret.Error(0)
}
return r0
}
// DeleteMessage provides a mock function with given fields: ctx, messageId
func (_m *MockSessionService) DeleteMessage(ctx context.Context, messageId int64) error {
ret := _m.Called(ctx, messageId)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int64) error); ok {
r0 = rf(ctx, messageId)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetMessage provides a mock function with given fields: ctx, lastMessageId
func (_m *MockSessionService) GetMessage(ctx context.Context, lastMessageId int64) (*RunnerScaleSetMessage, error) {
ret := _m.Called(ctx, lastMessageId)
var r0 *RunnerScaleSetMessage
if rf, ok := ret.Get(0).(func(context.Context, int64) *RunnerScaleSetMessage); ok {
r0 = rf(ctx, lastMessageId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*RunnerScaleSetMessage)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int64) error); ok {
r1 = rf(ctx, lastMessageId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
type mockConstructorTestingTNewMockSessionService interface {
mock.TestingT
Cleanup(func())
}
// NewMockSessionService creates a new instance of MockSessionService. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockSessionService(t mockConstructorTestingTNewMockSessionService) *MockSessionService {
mock := &MockSessionService{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,164 @@
package actions
import (
"context"
"fmt"
"net/url"
"strconv"
"sync"
"github.com/go-logr/logr"
)
type MultiClient interface {
GetClientFor(ctx context.Context, githubConfigURL string, creds ActionsAuth, namespace string) (ActionsService, error)
GetClientFromSecret(ctx context.Context, githubConfigURL, namespace string, secretData KubernetesSecretData) (ActionsService, error)
}
type multiClient struct {
// To lock adding and removing of individual clients.
mu sync.Mutex
clients map[ActionsClientKey]*actionsClientWrapper
logger logr.Logger
userAgent string
}
type GitHubAppAuth struct {
AppID int64
AppInstallationID int64
AppPrivateKey string
}
type ActionsAuth struct {
// GitHub App
AppCreds *GitHubAppAuth
// GitHub PAT
Token string
}
type ActionsClientKey struct {
ActionsURL string
Auth ActionsAuth
Namespace string
}
type actionsClientWrapper struct {
// To lock client usage when tokens are being refreshed.
mu sync.Mutex
client ActionsService
}
func NewMultiClient(userAgent string, logger logr.Logger) MultiClient {
return &multiClient{
mu: sync.Mutex{},
clients: make(map[ActionsClientKey]*actionsClientWrapper),
logger: logger,
userAgent: userAgent,
}
}
func (m *multiClient) GetClientFor(ctx context.Context, githubConfigURL string, creds ActionsAuth, namespace string) (ActionsService, error) {
m.logger.Info("retrieve actions client", "githubConfigURL", githubConfigURL, "namespace", namespace)
parsedGitHubURL, err := url.Parse(githubConfigURL)
if err != nil {
return nil, err
}
if creds.Token == "" && creds.AppCreds == nil {
return nil, fmt.Errorf("no credentials provided. either a PAT or GitHub App credentials should be provided")
}
if creds.Token != "" && creds.AppCreds != nil {
return nil, fmt.Errorf("both PAT and GitHub App credentials provided. should only provide one")
}
key := ActionsClientKey{
ActionsURL: parsedGitHubURL.String(),
Namespace: namespace,
}
if creds.AppCreds != nil {
key.Auth = ActionsAuth{
AppCreds: creds.AppCreds,
}
}
if creds.Token != "" {
key.Auth = ActionsAuth{
Token: creds.Token,
}
}
m.mu.Lock()
defer m.mu.Unlock()
clientWrapper, has := m.clients[key]
if has {
m.logger.Info("using cache client", "githubConfigURL", githubConfigURL, "namespace", namespace)
return clientWrapper.client, nil
}
m.logger.Info("creating new client", "githubConfigURL", githubConfigURL, "namespace", namespace)
client, err := NewClient(ctx, githubConfigURL, &creds, m.userAgent, m.logger)
if err != nil {
return nil, err
}
m.clients[key] = &actionsClientWrapper{
mu: sync.Mutex{},
client: client,
}
m.logger.Info("successfully created new client", "githubConfigURL", githubConfigURL, "namespace", namespace)
return client, nil
}
type KubernetesSecretData map[string][]byte
func (m *multiClient) GetClientFromSecret(ctx context.Context, githubConfigURL, namespace string, secretData KubernetesSecretData) (ActionsService, error) {
if len(secretData) == 0 {
return nil, fmt.Errorf("must provide secret data with either PAT or GitHub App Auth")
}
token := string(secretData["github_token"])
hasToken := len(token) > 0
appID := string(secretData["github_app_id"])
appInstallationID := string(secretData["github_app_installation_id"])
appPrivateKey := string(secretData["github_app_private_key"])
hasGitHubAppAuth := len(appID) > 0 && len(appInstallationID) > 0 && len(appPrivateKey) > 0
if hasToken && hasGitHubAppAuth {
return nil, fmt.Errorf("must provide secret with only PAT or GitHub App Auth to avoid ambiguity in client behavior")
}
if !hasToken && !hasGitHubAppAuth {
return nil, fmt.Errorf("neither PAT nor GitHub App Auth credentials provided in secret")
}
auth := ActionsAuth{}
if hasToken {
auth.Token = token
return m.GetClientFor(ctx, githubConfigURL, auth, namespace)
}
parsedAppID, err := strconv.ParseInt(appID, 10, 64)
if err != nil {
return nil, err
}
parsedAppInstallationID, err := strconv.ParseInt(appInstallationID, 10, 64)
if err != nil {
return nil, err
}
auth.AppCreds = &GitHubAppAuth{AppID: parsedAppID, AppInstallationID: parsedAppInstallationID, AppPrivateKey: appPrivateKey}
return m.GetClientFor(ctx, githubConfigURL, auth, namespace)
}

View File

@@ -0,0 +1,163 @@
package actions
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/actions/actions-runner-controller/logging"
)
func TestAddClient(t *testing.T) {
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: creating logger: %v\n", err)
os.Exit(1)
}
multiClient := NewMultiClient("test-user-agent", logger).(*multiClient)
ctx := context.Background()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasSuffix(r.URL.Path, "actions/runners/registration-token") {
w.WriteHeader(http.StatusCreated)
w.Header().Set("Content-Type", "application/json")
token := "abc-123"
rt := &registrationToken{Token: &token}
if err := json.NewEncoder(w).Encode(rt); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if strings.HasSuffix(r.URL.Path, "actions/runner-registration") {
w.Header().Set("Content-Type", "application/json")
url := "actions.github.com/abc"
jwt := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjI1MTYyMzkwMjJ9.tlrHslTmDkoqnc4Kk9ISoKoUNDfHo-kjlH-ByISBqzE"
adminConnInfo := &ActionsServiceAdminConnection{ActionsServiceUrl: &url, AdminToken: &jwt}
if err := json.NewEncoder(w).Encode(adminConnInfo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
if strings.HasSuffix(r.URL.Path, "/access_tokens") {
w.Header().Set("Content-Type", "application/vnd.github+json")
t, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z07:00")
accessToken := &accessToken{
Token: "abc-123",
ExpiresAt: t,
}
if err := json.NewEncoder(w).Encode(accessToken); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
}))
defer srv.Close()
want := 1
if _, err := multiClient.GetClientFor(ctx, fmt.Sprintf("%v/github/github", srv.URL), ActionsAuth{Token: "PAT"}, "namespace"); err != nil {
t.Fatal(err)
}
want++ // New repo
if _, err := multiClient.GetClientFor(ctx, fmt.Sprintf("%v/github/actions", srv.URL), ActionsAuth{Token: "PAT"}, "namespace"); err != nil {
t.Fatal(err)
}
// Repeat
if _, err := multiClient.GetClientFor(ctx, fmt.Sprintf("%v/github/github", srv.URL), ActionsAuth{Token: "PAT"}, "namespace"); err != nil {
t.Fatal(err)
}
want++ // New namespace
if _, err := multiClient.GetClientFor(ctx, fmt.Sprintf("%v/github/github", srv.URL), ActionsAuth{Token: "PAT"}, "other"); err != nil {
t.Fatal(err)
}
want++ // New pat
if _, err := multiClient.GetClientFor(ctx, fmt.Sprintf("%v/github/github", srv.URL), ActionsAuth{Token: "other"}, "other"); err != nil {
t.Fatal(err)
}
want++ // New org
if _, err := multiClient.GetClientFor(ctx, fmt.Sprintf("%v/github", srv.URL), ActionsAuth{Token: "PAT"}, "other"); err != nil {
t.Fatal(err)
}
// No org, repo, enterprise
if _, err := multiClient.GetClientFor(ctx, fmt.Sprintf("%v", srv.URL), ActionsAuth{Token: "PAT"}, "other"); err == nil {
t.Fatal(err)
}
want++ // Test keying on GitHub App
appAuth := &GitHubAppAuth{
AppID: 1,
AppPrivateKey: `-----BEGIN RSA PRIVATE KEY-----
MIICWgIBAAKBgHXfRT9cv9UY9fAAD4+1RshpfSSZe277urfEmPfX3/Og9zJYRk//
CZrJVD1CaBZDiIyQsNEzjta7r4UsqWdFOggiNN2E7ZTFQjMSaFkVgrzHqWuiaCBf
/BjbKPn4SMDmTzHvIe7Nel76hBdCaVgu6mYCW5jmuSH5qz/yR1U1J/WJAgMBAAEC
gYARWGWsSU3BYgbu5lNj5l0gKMXNmPhdAJYdbMTF0/KUu18k/XB7XSBgsre+vALt
I8r4RGKApoGif8P4aPYUyE8dqA1bh0X3Fj1TCz28qoUL5//dA+pigCRS20H7HM3C
ojoqF7+F+4F2sXmzFNd1NgY5RxFPYosTT7OnUiFuu2IisQJBALnMLe09LBnjuHXR
xxR65DDNxWPQLBjW3dL+ubLcwr7922l6ZIQsVjdeE0ItEUVRjjJ9/B/Jq9VJ/Lw4
g9LCkkMCQQCiaM2f7nYmGivPo9hlAbq5lcGJ5CCYFfeeYzTxMqum7Mbqe4kk5lgb
X6gWd0Izg2nGdAEe/97DClO6VpKcPbpDAkBTR/JOJN1fvXMxXJaf13XxakrQMr+R
Yr6LlSInykyAz8lJvlLP7A+5QbHgN9NF/wh+GXqpxPwA3ukqdSqhjhWBAkBn6mDv
HPgR5xrzL6XM8y9TgaOlJAdK6HtYp6d/UOmN0+Butf6JUq07TphRT5tXNJVgemch
O5x/9UKfbrc+KyzbAkAo97TfFC+mZhU1N5fFelaRu4ikPxlp642KRUSkOh8GEkNf
jQ97eJWiWtDcsMUhcZgoB5ydHcFlrBIn6oBcpge5
-----END RSA PRIVATE KEY-----`,
}
if _, err := multiClient.GetClientFor(ctx, fmt.Sprintf("%v/github/github", srv.URL), ActionsAuth{AppCreds: appAuth}, "other"); err != nil {
t.Fatal(err)
}
// Repeat last to verify GitHub App keys are mapped together
if _, err := multiClient.GetClientFor(ctx, fmt.Sprintf("%v/github/github", srv.URL), ActionsAuth{AppCreds: appAuth}, "other"); err != nil {
t.Fatal(err)
}
if len(multiClient.clients) != want {
t.Fatalf("GetClientFor: unexpected number of clients: got=%v want=%v", len(multiClient.clients), want)
}
}
func TestCreateJWT(t *testing.T) {
key := `-----BEGIN RSA PRIVATE KEY-----
MIICWgIBAAKBgHXfRT9cv9UY9fAAD4+1RshpfSSZe277urfEmPfX3/Og9zJYRk//
CZrJVD1CaBZDiIyQsNEzjta7r4UsqWdFOggiNN2E7ZTFQjMSaFkVgrzHqWuiaCBf
/BjbKPn4SMDmTzHvIe7Nel76hBdCaVgu6mYCW5jmuSH5qz/yR1U1J/WJAgMBAAEC
gYARWGWsSU3BYgbu5lNj5l0gKMXNmPhdAJYdbMTF0/KUu18k/XB7XSBgsre+vALt
I8r4RGKApoGif8P4aPYUyE8dqA1bh0X3Fj1TCz28qoUL5//dA+pigCRS20H7HM3C
ojoqF7+F+4F2sXmzFNd1NgY5RxFPYosTT7OnUiFuu2IisQJBALnMLe09LBnjuHXR
xxR65DDNxWPQLBjW3dL+ubLcwr7922l6ZIQsVjdeE0ItEUVRjjJ9/B/Jq9VJ/Lw4
g9LCkkMCQQCiaM2f7nYmGivPo9hlAbq5lcGJ5CCYFfeeYzTxMqum7Mbqe4kk5lgb
X6gWd0Izg2nGdAEe/97DClO6VpKcPbpDAkBTR/JOJN1fvXMxXJaf13XxakrQMr+R
Yr6LlSInykyAz8lJvlLP7A+5QbHgN9NF/wh+GXqpxPwA3ukqdSqhjhWBAkBn6mDv
HPgR5xrzL6XM8y9TgaOlJAdK6HtYp6d/UOmN0+Butf6JUq07TphRT5tXNJVgemch
O5x/9UKfbrc+KyzbAkAo97TfFC+mZhU1N5fFelaRu4ikPxlp642KRUSkOh8GEkNf
jQ97eJWiWtDcsMUhcZgoB5ydHcFlrBIn6oBcpge5
-----END RSA PRIVATE KEY-----`
auth := &GitHubAppAuth{
AppID: 123,
AppPrivateKey: key,
}
jwt, err := createJWTForGitHubApp(auth)
if err != nil {
t.Fatal(err)
}
fmt.Println(jwt)
}

View File

@@ -0,0 +1,14 @@
package actions
import (
"context"
"io"
)
//go:generate mockery --inpackage --name=SessionService
type SessionService interface {
GetMessage(ctx context.Context, lastMessageId int64) (*RunnerScaleSetMessage, error)
DeleteMessage(ctx context.Context, messageId int64) error
AcquireJobs(ctx context.Context, requestIds []int64) ([]int64, error)
io.Closer
}

153
github/actions/types.go Normal file
View File

@@ -0,0 +1,153 @@
package actions
import (
"time"
"github.com/google/uuid"
)
type AcquirableJobList struct {
Count int `json:"count"`
Jobs []AcquirableJob `json:"value"`
}
type AcquirableJob struct {
AcquireJobUrl string `json:"acquireJobUrl"`
MessageType string `json:"messageType"`
RunnerRequestId int64 `json:"runnerRequestId"`
RepositoryName string `json:"repositoryName"`
OwnerName string `json:"ownerName"`
JobWorkflowRef string `json:"jobWorkflowRef"`
EventName string `json:"eventName"`
RequestLabels []string `json:"requestLabels"`
}
type Int64List struct {
Count int `json:"count"`
Value []int64 `json:"value"`
}
type JobAvailable struct {
AcquireJobUrl string `json:"acquireJobUrl"`
JobMessageBase
}
type JobAssigned struct {
JobMessageBase
}
type JobStarted struct {
RunnerId int `json:"runnerId"`
RunnerName string `json:"runnerName"`
JobMessageBase
}
type JobCompleted struct {
Result string `json:"result"`
RunnerId int `json:"runnerId"`
RunnerName string `json:"runnerName"`
JobMessageBase
}
type JobMessageType struct {
MessageType string `json:"messageType"`
}
type JobMessageBase struct {
JobMessageType
RunnerRequestId int64 `json:"runnerRequestId"`
RepositoryName string `json:"repositoryName"`
OwnerName string `json:"ownerName"`
JobWorkflowRef string `json:"jobWorkflowRef"`
JobDisplayName string `json:"jobDisplayName"`
WorkflowRunId int64 `json:"workflowRunId"`
EventName string `json:"eventName"`
RequestLabels []string `json:"requestLabels"`
}
type Label struct {
Type string `json:"type"`
Name string `json:"name"`
}
type RunnerGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
IsDefault bool `json:"isDefaultGroup"`
}
type RunnerGroupList struct {
Count int `json:"count"`
RunnerGroups []RunnerGroup `json:"value"`
}
type RunnerScaleSet struct {
Id int `json:"id,omitempty"`
Name string `json:"name,omitempty"`
RunnerGroupId int `json:"runnerGroupId,omitempty"`
RunnerGroupName string `json:"runnerGroupName,omitempty"`
Labels []Label `json:"labels,omitempty"`
RunnerSetting RunnerSetting `json:"RunnerSetting,omitempty"`
CreatedOn time.Time `json:"createdOn,omitempty"`
RunnerJitConfigUrl string `json:"runnerJitConfigUrl,omitempty"`
Statistics *RunnerScaleSetStatistic `json:"statistics,omitempty"`
}
type RunnerScaleSetJitRunnerSetting struct {
Name string `json:"name"`
WorkFolder string `json:"workFolder"`
}
type RunnerScaleSetMessage struct {
MessageId int64 `json:"messageId"`
MessageType string `json:"messageType"`
Body string `json:"body"`
Statistics *RunnerScaleSetStatistic `json:"statistics"`
}
type runnerScaleSetsResponse struct {
Count int `json:"count"`
RunnerScaleSets []RunnerScaleSet `json:"value"`
}
type RunnerScaleSetSession struct {
SessionId *uuid.UUID `json:"sessionId,omitempty"`
OwnerName string `json:"ownerName,omitempty"`
RunnerScaleSet *RunnerScaleSet `json:"runnerScaleSet,omitempty"`
MessageQueueUrl string `json:"messageQueueUrl,omitempty"`
MessageQueueAccessToken string `json:"messageQueueAccessToken,omitempty"`
Statistics *RunnerScaleSetStatistic `json:"statistics,omitempty"`
}
type RunnerScaleSetStatistic struct {
TotalAvailableJobs int `json:"totalAvailableJobs"`
TotalAcquiredJobs int `json:"totalAcquiredJobs"`
TotalAssignedJobs int `json:"totalAssignedJobs"`
TotalRunningJobs int `json:"totalRunningJobs"`
TotalRegisteredRunners int `json:"totalRegisteredRunners"`
TotalBusyRunners int `json:"totalBusyRunners"`
TotalIdleRunners int `json:"totalIdleRunners"`
}
type RunnerSetting struct {
Ephemeral bool `json:"ephemeral,omitempty"`
IsElastic bool `json:"isElastic,omitempty"`
DisableUpdate bool `json:"disableUpdate,omitempty"`
}
type RunnerReferenceList struct {
Count int `json:"count"`
RunnerReferences []RunnerReference `json:"value"`
}
type RunnerReference struct {
Id int `json:"id"`
Name string `json:"name"`
RunnerScaleSetId int `json:"runnerScaleSetId"`
}
type RunnerScaleSetJitRunnerConfig struct {
Runner *RunnerReference `json:"runner"`
EncodedJITConfig string `json:"encodedJITConfig"`
}