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

View File

@@ -0,0 +1,129 @@
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
jsonpatch "github.com/evanphx/json-patch"
"github.com/go-logr/logr"
"k8s.io/apimachinery/pkg/types"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
type AutoScalerKubernetesManager struct {
*kubernetes.Clientset
logger logr.Logger
}
func NewKubernetesManager(logger *logr.Logger) (*AutoScalerKubernetesManager, error) {
conf, err := rest.InClusterConfig()
if err != nil {
return nil, err
}
kubeClient, err := kubernetes.NewForConfig(conf)
if err != nil {
return nil, err
}
var manager = &AutoScalerKubernetesManager{
Clientset: kubeClient,
logger: logger.WithName("KubernetesManager"),
}
return manager, nil
}
func (k *AutoScalerKubernetesManager) ScaleEphemeralRunnerSet(ctx context.Context, namespace, resourceName string, runnerCount int) error {
original := &v1alpha1.EphemeralRunnerSet{
Spec: v1alpha1.EphemeralRunnerSetSpec{
Replicas: -1,
},
}
originalJson, err := json.Marshal(original)
if err != nil {
k.logger.Error(err, "could not marshal empty ephemeral runner set")
}
patch := &v1alpha1.EphemeralRunnerSet{
Spec: v1alpha1.EphemeralRunnerSetSpec{
Replicas: runnerCount,
},
}
patchJson, err := json.Marshal(patch)
if err != nil {
k.logger.Error(err, "could not marshal patch ephemeral runner set")
}
mergePatch, err := jsonpatch.CreateMergePatch(originalJson, patchJson)
if err != nil {
k.logger.Error(err, "could not create merge patch json for ephemeral runner set")
}
k.logger.Info("Created merge patch json for EphemeralRunnerSet update", "json", string(mergePatch))
patchedEphemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{}
err = k.RESTClient().
Patch(types.MergePatchType).
Prefix("apis", "actions.github.com", "v1alpha1").
Namespace(namespace).
Resource("EphemeralRunnerSets").
Name(resourceName).
Body([]byte(mergePatch)).
Do(ctx).
Into(patchedEphemeralRunnerSet)
if err != nil {
return fmt.Errorf("could not patch ephemeral runner set , patch JSON: %s, error: %w", string(mergePatch), err)
}
k.logger.Info("Ephemeral runner set scaled.", "namespace", namespace, "name", resourceName, "replicas", patchedEphemeralRunnerSet.Spec.Replicas)
return nil
}
func (k *AutoScalerKubernetesManager) UpdateEphemeralRunnerWithJobInfo(ctx context.Context, namespace, resourceName, ownerName, repositoryName, jobWorkflowRef, jobDisplayName string, workflowRunId, jobRequestId int64) error {
original := &v1alpha1.EphemeralRunner{}
originalJson, err := json.Marshal(original)
if err != nil {
return fmt.Errorf("could not marshal empty ephemeral runner, error: %w", err)
}
patch := &v1alpha1.EphemeralRunner{
Status: v1alpha1.EphemeralRunnerStatus{
JobRequestId: jobRequestId,
JobRepositoryName: fmt.Sprintf("%s/%s", ownerName, repositoryName),
WorkflowRunId: workflowRunId,
JobWorkflowRef: jobWorkflowRef,
JobDisplayName: jobDisplayName,
},
}
patchedJson, err := json.Marshal(patch)
if err != nil {
return fmt.Errorf("could not marshal patched ephemeral runner, error: %w", err)
}
mergePatch, err := jsonpatch.CreateMergePatch(originalJson, patchedJson)
if err != nil {
k.logger.Error(err, "could not create merge patch json for ephemeral runner")
}
k.logger.Info("Created merge patch json for EphemeralRunner status update", "json", string(mergePatch))
patchedStatus := &v1alpha1.EphemeralRunner{}
err = k.RESTClient().
Patch(types.MergePatchType).
Prefix("apis", "actions.github.com", "v1alpha1").
Namespace(namespace).
Resource("EphemeralRunners").
Name(resourceName).
SubResource("status").
Body(mergePatch).
Do(ctx).
Into(patchedStatus)
if err != nil {
return fmt.Errorf("could not patch ephemeral runner status, patch JSON: %s, error: %w", string(mergePatch), err)
}
return nil
}

View File

@@ -0,0 +1,184 @@
package main
import (
"context"
"encoding/json"
"fmt"
"math/rand"
"net/http"
"os"
"time"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/go-logr/logr"
"github.com/google/uuid"
"github.com/pkg/errors"
)
const (
sessionCreationMaxRetryCount = 10
)
type devContextKey bool
var testIgnoreSleep devContextKey = true
type AutoScalerClient struct {
client actions.SessionService
logger logr.Logger
lastMessageId int64
initialMessage *actions.RunnerScaleSetMessage
}
func NewAutoScalerClient(
ctx context.Context,
client actions.ActionsService,
logger *logr.Logger,
runnerScaleSetId int,
options ...func(*AutoScalerClient),
) (*AutoScalerClient, error) {
listener := AutoScalerClient{
logger: logger.WithName("auto_scaler"),
}
session, initialMessage, err := createSession(ctx, &listener.logger, client, runnerScaleSetId)
if err != nil {
return nil, fmt.Errorf("fail to create session. %w", err)
}
listener.lastMessageId = 0
listener.initialMessage = initialMessage
listener.client = newSessionClient(client, logger, session)
for _, option := range options {
option(&listener)
}
return &listener, nil
}
func createSession(ctx context.Context, logger *logr.Logger, client actions.ActionsService, runnerScaleSetId int) (*actions.RunnerScaleSetSession, *actions.RunnerScaleSetMessage, error) {
hostName, err := os.Hostname()
if err != nil {
hostName = uuid.New().String()
logger.Info("could not get hostname, fail back to a random string.", "fallback", hostName)
}
var runnerScaleSetSession *actions.RunnerScaleSetSession
var retryCount int
for {
runnerScaleSetSession, err = client.CreateMessageSession(ctx, runnerScaleSetId, hostName)
if err == nil {
break
}
clientSideError := &actions.HttpClientSideError{}
if errors.As(err, &clientSideError) && clientSideError.Code != http.StatusConflict {
logger.Info("unable to create message session. The error indicates something is wrong on the client side, won't make any retry.")
return nil, nil, fmt.Errorf("create message session http request failed. %w", err)
}
retryCount++
if retryCount >= sessionCreationMaxRetryCount {
return nil, nil, fmt.Errorf("create message session failed since it exceed %d retry limit. %w", sessionCreationMaxRetryCount, err)
}
logger.Info("unable to create message session. Will try again in 30 seconds", "error", err.Error())
if ok := ctx.Value(testIgnoreSleep); ok == nil {
time.Sleep(getRandomDuration(30, 45))
}
}
statistics, _ := json.Marshal(runnerScaleSetSession.Statistics)
logger.Info("current runner scale set statistics.", "statistics", string(statistics))
if runnerScaleSetSession.Statistics.TotalAvailableJobs > 0 || runnerScaleSetSession.Statistics.TotalAssignedJobs > 0 {
acquirableJobs, err := client.GetAcquirableJobs(ctx, runnerScaleSetId)
if err != nil {
return nil, nil, fmt.Errorf("get acquirable jobs failed. %w", err)
}
acquirableJobsJson, err := json.Marshal(acquirableJobs.Jobs)
if err != nil {
return nil, nil, fmt.Errorf("marshal acquirable jobs failed. %w", err)
}
initialMessage := &actions.RunnerScaleSetMessage{
MessageId: 0,
MessageType: "RunnerScaleSetJobMessages",
Statistics: runnerScaleSetSession.Statistics,
Body: string(acquirableJobsJson),
}
return runnerScaleSetSession, initialMessage, nil
}
return runnerScaleSetSession, nil, nil
}
func (m *AutoScalerClient) Close() error {
m.logger.Info("closing.")
return m.client.Close()
}
func (m *AutoScalerClient) GetRunnerScaleSetMessage(ctx context.Context, handler func(msg *actions.RunnerScaleSetMessage) error) error {
if m.initialMessage != nil {
err := handler(m.initialMessage)
if err != nil {
return fmt.Errorf("fail to process initial message. %w", err)
}
m.initialMessage = nil
return nil
}
for {
message, err := m.client.GetMessage(ctx, m.lastMessageId)
if err != nil {
return fmt.Errorf("get message failed from refreshing client. %w", err)
}
if message == nil {
continue
}
err = handler(message)
if err != nil {
return fmt.Errorf("handle message failed. %w", err)
}
m.lastMessageId = message.MessageId
return m.deleteMessage(ctx, message.MessageId)
}
}
func (m *AutoScalerClient) deleteMessage(ctx context.Context, messageId int64) error {
err := m.client.DeleteMessage(ctx, messageId)
if err != nil {
return fmt.Errorf("delete message failed from refreshing client. %w", err)
}
m.logger.Info("deleted message.", "messageId", messageId)
return nil
}
func (m *AutoScalerClient) AcquireJobsForRunnerScaleSet(ctx context.Context, requestIds []int64) error {
m.logger.Info("acquiring jobs.", "request count", len(requestIds), "requestIds", fmt.Sprint(requestIds))
if len(requestIds) == 0 {
return nil
}
ids, err := m.client.AcquireJobs(ctx, requestIds)
if err != nil {
return fmt.Errorf("acquire jobs failed from refreshing client. %w", err)
}
m.logger.Info("acquired jobs.", "requested", len(requestIds), "acquired", len(ids))
return nil
}
func getRandomDuration(minSeconds, maxSeconds int) time.Duration {
return time.Duration(rand.Intn(maxSeconds-minSeconds)+minSeconds) * time.Second
}

View File

@@ -0,0 +1,701 @@
package main
import (
"context"
"fmt"
"testing"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/actions/actions-runner-controller/logging"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestCreateSession(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1)
require.NoError(t, err, "Error creating autoscaler client")
assert.Equal(t, session, session, "Session is not correct")
assert.Nil(t, asClient.initialMessage, "Initial message should be nil")
assert.Equal(t, int64(0), asClient.lastMessageId, "Last message id should be 0")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestCreateSession_CreateInitMessage(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{
TotalAvailableJobs: 1,
TotalAssignedJobs: 5,
},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockActionsClient.On("GetAcquirableJobs", ctx, 1).Return(&actions.AcquirableJobList{
Count: 1,
Jobs: []actions.AcquirableJob{
{
RunnerRequestId: 1,
OwnerName: "owner",
RepositoryName: "repo",
AcquireJobUrl: "https://github.com",
},
},
}, nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1)
require.NoError(t, err, "Error creating autoscaler client")
assert.Equal(t, session, session, "Session is not correct")
assert.NotNil(t, asClient.initialMessage, "Initial message should not be nil")
assert.Equal(t, int64(0), asClient.lastMessageId, "Last message id should be 0")
assert.Equal(t, int64(0), asClient.initialMessage.MessageId, "Initial message id should be 0")
assert.Equal(t, "RunnerScaleSetJobMessages", asClient.initialMessage.MessageType, "Initial message type should be RunnerScaleSetJobMessages")
assert.Equal(t, 5, asClient.initialMessage.Statistics.TotalAssignedJobs, "Initial message total assigned jobs should be 5")
assert.Equal(t, 1, asClient.initialMessage.Statistics.TotalAvailableJobs, "Initial message total available jobs should be 1")
assert.Equal(t, "[{\"acquireJobUrl\":\"https://github.com\",\"messageType\":\"\",\"runnerRequestId\":1,\"repositoryName\":\"repo\",\"ownerName\":\"owner\",\"jobWorkflowRef\":\"\",\"eventName\":\"\",\"requestLabels\":null}]", asClient.initialMessage.Body, "Initial message body is not correct")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestCreateSession_CreateInitMessageWithOnlyAssignedJobs(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{
TotalAssignedJobs: 5,
},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockActionsClient.On("GetAcquirableJobs", ctx, 1).Return(&actions.AcquirableJobList{
Count: 0,
Jobs: []actions.AcquirableJob{},
}, nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1)
require.NoError(t, err, "Error creating autoscaler client")
assert.Equal(t, session, session, "Session is not correct")
assert.NotNil(t, asClient.initialMessage, "Initial message should not be nil")
assert.Equal(t, int64(0), asClient.lastMessageId, "Last message id should be 0")
assert.Equal(t, int64(0), asClient.initialMessage.MessageId, "Initial message id should be 0")
assert.Equal(t, "RunnerScaleSetJobMessages", asClient.initialMessage.MessageType, "Initial message type should be RunnerScaleSetJobMessages")
assert.Equal(t, 5, asClient.initialMessage.Statistics.TotalAssignedJobs, "Initial message total assigned jobs should be 5")
assert.Equal(t, 0, asClient.initialMessage.Statistics.TotalAvailableJobs, "Initial message total available jobs should be 0")
assert.Equal(t, "[]", asClient.initialMessage.Body, "Initial message body is not correct")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestCreateSession_CreateInitMessageFailed(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{
TotalAvailableJobs: 1,
TotalAssignedJobs: 5,
},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockActionsClient.On("GetAcquirableJobs", ctx, 1).Return(nil, fmt.Errorf("error"))
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1)
assert.ErrorContains(t, err, "get acquirable jobs failed. error", "Unexpected error")
assert.Nil(t, asClient, "Client should be nil")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestCreateSession_RetrySessionConflict(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.WithValue(context.Background(), testIgnoreSleep, true)
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(nil, &actions.HttpClientSideError{
Code: 409,
}).Once()
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil).Once()
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1)
require.NoError(t, err, "Error creating autoscaler client")
assert.Equal(t, session, session, "Session is not correct")
assert.Nil(t, asClient.initialMessage, "Initial message should be nil")
assert.Equal(t, int64(0), asClient.lastMessageId, "Last message id should be 0")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestCreateSession_RetrySessionConflict_RunOutOfRetry(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.WithValue(context.Background(), testIgnoreSleep, true)
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(nil, &actions.HttpClientSideError{
Code: 409,
})
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1)
assert.Error(t, err, "Error should be returned")
assert.Nil(t, asClient, "AutoScaler should be nil")
assert.True(t, mockActionsClient.AssertNumberOfCalls(t, "CreateMessageSession", sessionCreationMaxRetryCount), "CreateMessageSession should be called 10 times")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestCreateSession_NotRetryOnGeneralException(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.WithValue(context.Background(), testIgnoreSleep, true)
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(nil, &actions.HttpClientSideError{
Code: 403,
})
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1)
assert.Error(t, err, "Error should be returned")
assert.Nil(t, asClient, "AutoScaler should be nil")
assert.True(t, mockActionsClient.AssertNumberOfCalls(t, "CreateMessageSession", 1), "CreateMessageSession should be called 1 time and not retry on generic error")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestDeleteSession(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockSessionClient.On("Close").Return(nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.Close()
assert.NoError(t, err, "Error deleting session")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockSessionClient.AssertExpectations(t), "All expectations should be met")
}
func TestDeleteSession_Failed(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockSessionClient.On("Close").Return(fmt.Errorf("error"))
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.Close()
assert.Error(t, err, "Error should be returned")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockSessionClient.AssertExpectations(t), "All expectations should be met")
}
func TestGetRunnerScaleSetMessage(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockSessionClient.On("GetMessage", ctx, int64(0)).Return(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "test",
Body: "test",
}, nil)
mockSessionClient.On("DeleteMessage", ctx, int64(1)).Return(nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.GetRunnerScaleSetMessage(ctx, func(msg *actions.RunnerScaleSetMessage) error {
logger.Info("Message received", "messageId", msg.MessageId, "messageType", msg.MessageType, "body", msg.Body)
return nil
})
assert.NoError(t, err, "Error getting message")
assert.Equal(t, int64(1), asClient.lastMessageId, "Last message id should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockSessionClient.AssertExpectations(t), "All expectations should be met")
}
func TestGetRunnerScaleSetMessage_HandleFailed(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockSessionClient.On("GetMessage", ctx, int64(0)).Return(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "test",
Body: "test",
}, nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.GetRunnerScaleSetMessage(ctx, func(msg *actions.RunnerScaleSetMessage) error {
logger.Info("Message received", "messageId", msg.MessageId, "messageType", msg.MessageType, "body", msg.Body)
return fmt.Errorf("error")
})
assert.ErrorContains(t, err, "handle message failed. error", "Error getting message")
assert.Equal(t, int64(0), asClient.lastMessageId, "Last message id should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockSessionClient.AssertExpectations(t), "All expectations should be met")
}
func TestGetRunnerScaleSetMessage_HandleInitialMessage(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{
TotalAvailableJobs: 1,
TotalAssignedJobs: 2,
},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockActionsClient.On("GetAcquirableJobs", ctx, 1).Return(&actions.AcquirableJobList{
Count: 1,
Jobs: []actions.AcquirableJob{
{
RunnerRequestId: 1,
OwnerName: "owner",
RepositoryName: "repo",
AcquireJobUrl: "https://github.com",
},
},
}, nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1)
require.NoError(t, err, "Error creating autoscaler client")
require.NotNil(t, asClient.initialMessage, "Initial message should be set")
err = asClient.GetRunnerScaleSetMessage(ctx, func(msg *actions.RunnerScaleSetMessage) error {
logger.Info("Message received", "messageId", msg.MessageId, "messageType", msg.MessageType, "body", msg.Body)
return nil
})
assert.NoError(t, err, "Error getting message")
assert.Nil(t, asClient.initialMessage, "Initial message should be nil")
assert.Equal(t, int64(0), asClient.lastMessageId, "Last message id should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestGetRunnerScaleSetMessage_HandleInitialMessageFailed(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{
TotalAvailableJobs: 1,
TotalAssignedJobs: 2,
},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockActionsClient.On("GetAcquirableJobs", ctx, 1).Return(&actions.AcquirableJobList{
Count: 1,
Jobs: []actions.AcquirableJob{
{
RunnerRequestId: 1,
OwnerName: "owner",
RepositoryName: "repo",
AcquireJobUrl: "https://github.com",
},
},
}, nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1)
require.NoError(t, err, "Error creating autoscaler client")
require.NotNil(t, asClient.initialMessage, "Initial message should be set")
err = asClient.GetRunnerScaleSetMessage(ctx, func(msg *actions.RunnerScaleSetMessage) error {
logger.Info("Message received", "messageId", msg.MessageId, "messageType", msg.MessageType, "body", msg.Body)
return fmt.Errorf("error")
})
assert.ErrorContains(t, err, "fail to process initial message. error", "Error getting message")
assert.NotNil(t, asClient.initialMessage, "Initial message should be nil")
assert.Equal(t, int64(0), asClient.lastMessageId, "Last message id should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestGetRunnerScaleSetMessage_RetryUntilGetMessage(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockSessionClient.On("GetMessage", ctx, int64(0)).Return(nil, nil).Times(3)
mockSessionClient.On("GetMessage", ctx, int64(0)).Return(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "test",
Body: "test",
}, nil).Once()
mockSessionClient.On("DeleteMessage", ctx, int64(1)).Return(nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.GetRunnerScaleSetMessage(ctx, func(msg *actions.RunnerScaleSetMessage) error {
logger.Info("Message received", "messageId", msg.MessageId, "messageType", msg.MessageType, "body", msg.Body)
return nil
})
assert.NoError(t, err, "Error getting message")
assert.Equal(t, int64(1), asClient.lastMessageId, "Last message id should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestGetRunnerScaleSetMessage_ErrorOnGetMessage(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockSessionClient.On("GetMessage", ctx, int64(0)).Return(nil, fmt.Errorf("error"))
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.GetRunnerScaleSetMessage(ctx, func(msg *actions.RunnerScaleSetMessage) error {
return fmt.Errorf("Should not be called")
})
assert.ErrorContains(t, err, "get message failed from refreshing client. error", "Error should be returned")
assert.Equal(t, int64(0), asClient.lastMessageId, "Last message id should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockSessionClient.AssertExpectations(t), "All expectations should be met")
}
func TestDeleteRunnerScaleSetMessage_Error(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockSessionClient.On("GetMessage", ctx, int64(0)).Return(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "test",
Body: "test",
}, nil)
mockSessionClient.On("DeleteMessage", ctx, int64(1)).Return(fmt.Errorf("error"))
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.GetRunnerScaleSetMessage(ctx, func(msg *actions.RunnerScaleSetMessage) error {
logger.Info("Message received", "messageId", msg.MessageId, "messageType", msg.MessageType, "body", msg.Body)
return nil
})
assert.ErrorContains(t, err, "delete message failed from refreshing client. error", "Error getting message")
assert.Equal(t, int64(1), asClient.lastMessageId, "Last message id should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestAcquireJobsForRunnerScaleSet(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockSessionClient.On("AcquireJobs", ctx, mock.MatchedBy(func(ids []int64) bool { return ids[0] == 1 && ids[1] == 2 && ids[2] == 3 })).Return([]int64{1, 2, 3}, nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.AcquireJobsForRunnerScaleSet(ctx, []int64{1, 2, 3})
assert.NoError(t, err, "Error acquiring jobs")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockSessionClient.AssertExpectations(t), "All expectations should be met")
}
func TestAcquireJobsForRunnerScaleSet_SkipEmptyList(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.AcquireJobsForRunnerScaleSet(ctx, []int64{})
assert.NoError(t, err, "Error acquiring jobs")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockSessionClient.AssertExpectations(t), "All expectations should be met")
}
func TestAcquireJobsForRunnerScaleSet_Failed(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
mockSessionClient := &actions.MockSessionService{}
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
Statistics: &actions.RunnerScaleSetStatistic{},
}
mockActionsClient.On("CreateMessageSession", ctx, 1, mock.Anything).Return(session, nil)
mockSessionClient.On("AcquireJobs", ctx, mock.Anything).Return(nil, fmt.Errorf("error"))
asClient, err := NewAutoScalerClient(ctx, mockActionsClient, &logger, 1, func(asc *AutoScalerClient) {
asc.client = mockSessionClient
})
require.NoError(t, err, "Error creating autoscaler client")
err = asClient.AcquireJobsForRunnerScaleSet(ctx, []int64{1, 2, 3})
assert.ErrorContains(t, err, "acquire jobs failed from refreshing client. error", "Expect error acquiring jobs")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockSessionClient.AssertExpectations(t), "All expectations should be met")
}

View File

@@ -0,0 +1,185 @@
package main
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/go-logr/logr"
)
type ScaleSettings struct {
Namespace string
ResourceName string
MinRunners int
MaxRunners int
}
type Service struct {
ctx context.Context
logger logr.Logger
rsClient RunnerScaleSetClient
kubeManager KubernetesManager
settings *ScaleSettings
currentRunnerCount int
}
func NewService(
ctx context.Context,
rsClient RunnerScaleSetClient,
manager KubernetesManager,
settings *ScaleSettings,
options ...func(*Service),
) *Service {
s := &Service{
ctx: ctx,
rsClient: rsClient,
kubeManager: manager,
settings: settings,
currentRunnerCount: 0,
logger: logr.FromContextOrDiscard(ctx),
}
for _, option := range options {
option(s)
}
return s
}
func (s *Service) Start() error {
if s.settings.MinRunners > 0 {
s.logger.Info("scale to match minimal runners.")
err := s.scaleForAssignedJobCount(0)
if err != nil {
return fmt.Errorf("could not scale to match minimal runners. %w", err)
}
}
for {
s.logger.Info("waiting for message...")
select {
case <-s.ctx.Done():
s.logger.Info("service is stopped.")
return nil
default:
err := s.rsClient.GetRunnerScaleSetMessage(s.ctx, s.processMessage)
if err != nil {
return fmt.Errorf("could not get and process message. %w", err)
}
}
}
}
func (s *Service) processMessage(message *actions.RunnerScaleSetMessage) error {
s.logger.Info("process message.", "messageId", message.MessageId, "messageType", message.MessageType)
if message.Statistics == nil {
return fmt.Errorf("can't process message with empty statistics")
}
s.logger.Info("current runner scale set statistics.",
"available jobs", message.Statistics.TotalAvailableJobs,
"acquired jobs", message.Statistics.TotalAcquiredJobs,
"assigned jobs", message.Statistics.TotalAssignedJobs,
"running jobs", message.Statistics.TotalRunningJobs,
"registered runners", message.Statistics.TotalRegisteredRunners,
"busy runners", message.Statistics.TotalBusyRunners,
"idle runners", message.Statistics.TotalIdleRunners)
if message.MessageType != "RunnerScaleSetJobMessages" {
s.logger.Info("skip message with unknown message type.", "messageType", message.MessageType)
return nil
}
var batchedMessages []json.RawMessage
if err := json.NewDecoder(strings.NewReader(message.Body)).Decode(&batchedMessages); err != nil {
return fmt.Errorf("could not decode job messages. %w", err)
}
s.logger.Info("process batched runner scale set job messages.", "messageId", message.MessageId, "batchSize", len(batchedMessages))
var availableJobs []int64
for _, message := range batchedMessages {
var messageType actions.JobMessageType
if err := json.Unmarshal(message, &messageType); err != nil {
return fmt.Errorf("could not decode job message type. %w", err)
}
switch messageType.MessageType {
case "JobAvailable":
var jobAvailable actions.JobAvailable
if err := json.Unmarshal(message, &jobAvailable); err != nil {
return fmt.Errorf("could not decode job available message. %w", err)
}
s.logger.Info("job available message received.", "RequestId", jobAvailable.RunnerRequestId)
availableJobs = append(availableJobs, jobAvailable.RunnerRequestId)
case "JobAssigned":
var jobAssigned actions.JobAssigned
if err := json.Unmarshal(message, &jobAssigned); err != nil {
return fmt.Errorf("could not decode job assigned message. %w", err)
}
s.logger.Info("job assigned message received.", "RequestId", jobAssigned.RunnerRequestId)
case "JobStarted":
var jobStarted actions.JobStarted
if err := json.Unmarshal(message, &jobStarted); err != nil {
return fmt.Errorf("could not decode job started message. %w", err)
}
s.logger.Info("job started message received.", "RequestId", jobStarted.RunnerRequestId, "RunnerId", jobStarted.RunnerId)
s.updateJobInfoForRunner(jobStarted)
case "JobCompleted":
var jobCompleted actions.JobCompleted
if err := json.Unmarshal(message, &jobCompleted); err != nil {
return fmt.Errorf("could not decode job completed message. %w", err)
}
s.logger.Info("job completed message received.", "RequestId", jobCompleted.RunnerRequestId, "Result", jobCompleted.Result, "RunnerId", jobCompleted.RunnerId, "RunnerName", jobCompleted.RunnerName)
default:
s.logger.Info("unknown job message type.", "messageType", messageType.MessageType)
}
}
err := s.rsClient.AcquireJobsForRunnerScaleSet(s.ctx, availableJobs)
if err != nil {
return fmt.Errorf("could not acquire jobs. %w", err)
}
return s.scaleForAssignedJobCount(message.Statistics.TotalAssignedJobs)
}
func (s *Service) scaleForAssignedJobCount(count int) error {
targetRunnerCount := int(math.Max(math.Min(float64(s.settings.MaxRunners), float64(count)), float64(s.settings.MinRunners)))
if targetRunnerCount != s.currentRunnerCount {
s.logger.Info("try scale runner request up/down base on assigned job count",
"assigned job", count,
"decision", targetRunnerCount,
"min", s.settings.MinRunners,
"max", s.settings.MaxRunners,
"currentRunnerCount", s.currentRunnerCount)
err := s.kubeManager.ScaleEphemeralRunnerSet(s.ctx, s.settings.Namespace, s.settings.ResourceName, targetRunnerCount)
if err != nil {
return fmt.Errorf("could not scale ephemeral runner set (%s/%s). %w", s.settings.Namespace, s.settings.ResourceName, err)
}
s.currentRunnerCount = targetRunnerCount
}
return nil
}
// updateJobInfoForRunner updates the ephemeral runner with the job info and this is best effort since the info is only for better telemetry
func (s *Service) updateJobInfoForRunner(jobInfo actions.JobStarted) {
s.logger.Info("update job info for runner",
"runnerName", jobInfo.RunnerName,
"ownerName", jobInfo.OwnerName,
"repoName", jobInfo.RepositoryName,
"workflowRef", jobInfo.JobWorkflowRef,
"workflowRunId", jobInfo.WorkflowRunId,
"jobDisplayName", jobInfo.JobDisplayName,
"requestId", jobInfo.RunnerRequestId)
err := s.kubeManager.UpdateEphemeralRunnerWithJobInfo(s.ctx, s.settings.Namespace, jobInfo.RunnerName, jobInfo.OwnerName, jobInfo.RepositoryName, jobInfo.JobWorkflowRef, jobInfo.JobDisplayName, jobInfo.WorkflowRunId, jobInfo.RunnerRequestId)
if err != nil {
s.logger.Error(err, "could not update ephemeral runner with job info", "runnerName", jobInfo.RunnerName, "requestId", jobInfo.RunnerRequestId)
}
}

View File

@@ -0,0 +1,631 @@
package main
import (
"context"
"fmt"
"testing"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/actions/actions-runner-controller/logging"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestNewService(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
assert.Equal(t, logger, service.logger)
}
func TestStart(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockRsClient.On("GetRunnerScaleSetMessage", service.ctx, mock.Anything).Run(func(args mock.Arguments) { cancel() }).Return(nil).Once()
err := service.Start()
assert.NoError(t, err, "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestStart_ScaleToMinRunners(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 5,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 5).Run(func(args mock.Arguments) { cancel() }).Return(nil).Once()
err := service.Start()
assert.NoError(t, err, "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestStart_ScaleToMinRunnersFailed(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 5,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 5).Return(fmt.Errorf("error")).Once()
err := service.Start()
assert.ErrorContains(t, err, "could not scale to match minimal runners", "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestStart_GetMultipleMessages(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockRsClient.On("GetRunnerScaleSetMessage", service.ctx, mock.Anything).Return(nil).Times(5)
mockRsClient.On("GetRunnerScaleSetMessage", service.ctx, mock.Anything).Run(func(args mock.Arguments) { cancel() }).Return(nil).Once()
err := service.Start()
assert.NoError(t, err, "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestStart_ErrorOnMessage(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockRsClient.On("GetRunnerScaleSetMessage", service.ctx, mock.Anything).Return(nil).Times(2)
mockRsClient.On("GetRunnerScaleSetMessage", service.ctx, mock.Anything).Return(fmt.Errorf("error")).Once()
err := service.Start()
assert.ErrorContains(t, err, "could not get and process message. error", "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestProcessMessage_NoStatistic(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
err := service.processMessage(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "test",
Body: "test",
})
assert.ErrorContains(t, err, "can't process message with empty statistics", "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestProcessMessage_IgnoreUnknownMessageType(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
err := service.processMessage(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "unknown",
Statistics: &actions.RunnerScaleSetStatistic{
TotalAvailableJobs: 1,
},
Body: "[]",
})
assert.NoError(t, err, "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestProcessMessage_InvalidBatchMessageJson(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
err := service.processMessage(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "RunnerScaleSetJobMessages",
Statistics: &actions.RunnerScaleSetStatistic{
TotalAvailableJobs: 1,
},
Body: "invalid json",
})
assert.ErrorContains(t, err, "could not decode job messages", "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestProcessMessage_InvalidJobMessageJson(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
err := service.processMessage(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "RunnerScaleSetJobMessages",
Statistics: &actions.RunnerScaleSetStatistic{
TotalAvailableJobs: 1,
},
Body: "[\"something\", \"test\"]",
})
assert.ErrorContains(t, err, "could not decode job message type", "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestProcessMessage_MultipleMessages(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 1,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockRsClient.On("AcquireJobsForRunnerScaleSet", ctx, mock.MatchedBy(func(ids []int64) bool { return ids[0] == 3 && ids[1] == 4 })).Return(nil).Once()
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 2).Run(func(args mock.Arguments) { cancel() }).Return(nil).Once()
err := service.processMessage(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "RunnerScaleSetJobMessages",
Statistics: &actions.RunnerScaleSetStatistic{
TotalAssignedJobs: 2,
TotalAvailableJobs: 2,
},
Body: "[{\"messageType\":\"JobAvailable\", \"runnerRequestId\": 3},{\"messageType\":\"JobAvailable\", \"runnerRequestId\": 4},{\"messageType\":\"JobAssigned\", \"runnerRequestId\": 2}, {\"messageType\":\"JobCompleted\", \"runnerRequestId\": 1, \"result\":\"succeed\"},{\"messageType\":\"unknown\"}]",
})
assert.NoError(t, err, "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestProcessMessage_AcquireJobsFailed(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockRsClient.On("AcquireJobsForRunnerScaleSet", ctx, mock.MatchedBy(func(ids []int64) bool { return ids[0] == 1 })).Return(fmt.Errorf("error")).Once()
err := service.processMessage(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "RunnerScaleSetJobMessages",
Statistics: &actions.RunnerScaleSetStatistic{
TotalAssignedJobs: 1,
TotalAvailableJobs: 1,
},
Body: "[{\"messageType\":\"JobAvailable\", \"runnerRequestId\": 1}]",
})
assert.ErrorContains(t, err, "could not acquire jobs. error", "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestScaleForAssignedJobCount_DeDupScale(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 0,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 2).Return(nil).Once()
err := service.scaleForAssignedJobCount(2)
require.NoError(t, err, "Unexpected error")
err = service.scaleForAssignedJobCount(2)
require.NoError(t, err, "Unexpected error")
err = service.scaleForAssignedJobCount(2)
require.NoError(t, err, "Unexpected error")
err = service.scaleForAssignedJobCount(2)
assert.NoError(t, err, "Unexpected error")
assert.Equal(t, 2, service.currentRunnerCount, "Unexpected runner count")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestScaleForAssignedJobCount_ScaleWithinMinMax(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 1,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 1).Return(nil).Once()
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 3).Return(nil).Once()
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 5).Return(nil).Once()
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 1).Return(nil).Once()
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 5).Return(nil).Once()
err := service.scaleForAssignedJobCount(0)
require.NoError(t, err, "Unexpected error")
err = service.scaleForAssignedJobCount(3)
require.NoError(t, err, "Unexpected error")
err = service.scaleForAssignedJobCount(5)
require.NoError(t, err, "Unexpected error")
err = service.scaleForAssignedJobCount(1)
require.NoError(t, err, "Unexpected error")
err = service.scaleForAssignedJobCount(10)
assert.NoError(t, err, "Unexpected error")
assert.Equal(t, 5, service.currentRunnerCount, "Unexpected runner count")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestScaleForAssignedJobCount_ScaleFailed(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 1,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
mockKubeManager.On("ScaleEphemeralRunnerSet", ctx, service.settings.Namespace, service.settings.ResourceName, 2).Return(fmt.Errorf("error"))
err := service.scaleForAssignedJobCount(2)
assert.ErrorContains(t, err, "could not scale ephemeral runner set (namespace/resource). error", "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestProcessMessage_JobStartedMessage(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 1,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
service.currentRunnerCount = 1
mockKubeManager.On("UpdateEphemeralRunnerWithJobInfo", ctx, service.settings.Namespace, "runner1", "owner1", "repo1", ".github/workflows/ci.yaml", "job1", int64(100), int64(3)).Run(func(args mock.Arguments) { cancel() }).Return(nil).Once()
mockRsClient.On("AcquireJobsForRunnerScaleSet", ctx, mock.MatchedBy(func(ids []int64) bool { return len(ids) == 0 })).Return(nil).Once()
err := service.processMessage(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "RunnerScaleSetJobMessages",
Statistics: &actions.RunnerScaleSetStatistic{
TotalAssignedJobs: 1,
TotalAvailableJobs: 0,
},
Body: "[{\"messageType\":\"JobStarted\", \"runnerRequestId\": 3, \"runnerId\": 1, \"runnerName\": \"runner1\", \"ownerName\": \"owner1\", \"repositoryName\": \"repo1\", \"jobWorkflowRef\": \".github/workflows/ci.yaml\", \"jobDisplayName\": \"job1\", \"workflowRunId\": 100 }]",
})
assert.NoError(t, err, "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}
func TestProcessMessage_JobStartedMessageIgnoreRunnerUpdateError(t *testing.T) {
mockRsClient := &MockRunnerScaleSetClient{}
mockKubeManager := &MockKubernetesManager{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := NewService(
ctx,
mockRsClient,
mockKubeManager,
&ScaleSettings{
Namespace: "namespace",
ResourceName: "resource",
MinRunners: 1,
MaxRunners: 5,
},
func(s *Service) {
s.logger = logger
},
)
service.currentRunnerCount = 1
mockKubeManager.On("UpdateEphemeralRunnerWithJobInfo", ctx, service.settings.Namespace, "runner1", "owner1", "repo1", ".github/workflows/ci.yaml", "job1", int64(100), int64(3)).Run(func(args mock.Arguments) { cancel() }).Return(fmt.Errorf("error")).Once()
mockRsClient.On("AcquireJobsForRunnerScaleSet", ctx, mock.MatchedBy(func(ids []int64) bool { return len(ids) == 0 })).Return(nil).Once()
err := service.processMessage(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "RunnerScaleSetJobMessages",
Statistics: &actions.RunnerScaleSetStatistic{
TotalAssignedJobs: 0,
TotalAvailableJobs: 0,
},
Body: "[{\"messageType\":\"JobStarted\", \"runnerRequestId\": 3, \"runnerId\": 1, \"runnerName\": \"runner1\", \"ownerName\": \"owner1\", \"repositoryName\": \"repo1\", \"jobWorkflowRef\": \".github/workflows/ci.yaml\", \"jobDisplayName\": \"job1\", \"workflowRunId\": 100 }]",
})
assert.NoError(t, err, "Unexpected error")
assert.True(t, mockRsClient.AssertExpectations(t), "All expectations should be met")
assert.True(t, mockKubeManager.AssertExpectations(t), "All expectations should be met")
}

View File

@@ -0,0 +1,12 @@
package main
import (
"context"
)
//go:generate mockery --inpackage --name=KubernetesManager
type KubernetesManager interface {
ScaleEphemeralRunnerSet(ctx context.Context, namespace, resourceName string, runnerCount int) error
UpdateEphemeralRunnerWithJobInfo(ctx context.Context, namespace, resourceName, ownerName, repositoryName, jobWorkflowRef, jobDisplayName string, jobRequestId, workflowRunId int64) error
}

View File

@@ -0,0 +1,151 @@
/*
Copyright 2021 The actions-runner-controller authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/actions/actions-runner-controller/logging"
"github.com/go-logr/logr"
"github.com/kelseyhightower/envconfig"
)
type RunnerScaleSetListenerConfig struct {
ConfigureUrl string `split_words:"true"`
AppID int64 `split_words:"true"`
AppInstallationID int64 `split_words:"true"`
AppPrivateKey string `split_words:"true"`
Token string `split_words:"true"`
EphemeralRunnerSetNamespace string `split_words:"true"`
EphemeralRunnerSetName string `split_words:"true"`
MaxRunners int `split_words:"true"`
MinRunners int `split_words:"true"`
RunnerScaleSetId int `split_words:"true"`
}
func main() {
logger, err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: creating logger: %v\n", err)
os.Exit(1)
}
var rc RunnerScaleSetListenerConfig
if err := envconfig.Process("github", &rc); err != nil {
logger.Error(err, "Error: processing environment variables for RunnerScaleSetListenerConfig")
os.Exit(1)
}
// Validate all inputs
if err := validateConfig(&rc); err != nil {
logger.Error(err, "Inputs validation failed")
os.Exit(1)
}
if err := run(rc, logger); err != nil {
logger.Error(err, "Run error")
os.Exit(1)
}
}
func run(rc RunnerScaleSetListenerConfig, logger logr.Logger) error {
// Create root context and hook with sigint and sigterm
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
creds := &actions.ActionsAuth{}
if rc.Token != "" {
creds.Token = rc.Token
} else {
creds.AppCreds = &actions.GitHubAppAuth{
AppID: rc.AppID,
AppInstallationID: rc.AppInstallationID,
AppPrivateKey: rc.AppPrivateKey,
}
}
actionsServiceClient, err := actions.NewClient(ctx, rc.ConfigureUrl, creds, "actions-runner-controller", logger)
if err != nil {
return fmt.Errorf("failed to create an Actions Service client: %w", err)
}
// Create message listener
autoScalerClient, err := NewAutoScalerClient(ctx, actionsServiceClient, &logger, rc.RunnerScaleSetId)
if err != nil {
return fmt.Errorf("failed to create a message listener: %w", err)
}
defer autoScalerClient.Close()
// Create kube manager and scale controller
kubeManager, err := NewKubernetesManager(&logger)
if err != nil {
return fmt.Errorf("failed to create kubernetes manager: %w", err)
}
scaleSettings := &ScaleSettings{
Namespace: rc.EphemeralRunnerSetNamespace,
ResourceName: rc.EphemeralRunnerSetName,
MaxRunners: rc.MaxRunners,
MinRunners: rc.MinRunners,
}
service := NewService(ctx, autoScalerClient, kubeManager, scaleSettings, func(s *Service) {
s.logger = logger.WithName("service")
})
// Start listening for messages
if err = service.Start(); err != nil {
return fmt.Errorf("failed to start message queue listener: %w", err)
}
return nil
}
func validateConfig(config *RunnerScaleSetListenerConfig) error {
if len(config.ConfigureUrl) == 0 {
return fmt.Errorf("GitHubConfigUrl is not provided")
}
if len(config.EphemeralRunnerSetNamespace) == 0 || len(config.EphemeralRunnerSetName) == 0 {
return fmt.Errorf("EphemeralRunnerSetNamespace '%s' or EphemeralRunnerSetName '%s' is missing", config.EphemeralRunnerSetNamespace, config.EphemeralRunnerSetName)
}
if config.RunnerScaleSetId == 0 {
return fmt.Errorf("RunnerScaleSetId '%d' is missing", config.RunnerScaleSetId)
}
if config.MaxRunners < config.MinRunners {
return fmt.Errorf("MinRunners '%d' cannot be greater than MaxRunners '%d'", config.MinRunners, config.MaxRunners)
}
hasToken := len(config.Token) > 0
hasPrivateKeyConfig := config.AppID > 0 && config.AppPrivateKey != ""
if !hasToken && !hasPrivateKeyConfig {
return fmt.Errorf("GitHub auth credential is missing, token length: '%d', appId: '%d', installationId: '%d', private key length: '%d", len(config.Token), config.AppID, config.AppInstallationID, len(config.AppPrivateKey))
}
if hasToken && hasPrivateKeyConfig {
return fmt.Errorf("only one GitHub auth method supported at a time. Have both PAT and App auth: token length: '%d', appId: '%d', installationId: '%d', private key length: '%d", len(config.Token), config.AppID, config.AppInstallationID, len(config.AppPrivateKey))
}
return nil
}

View File

@@ -0,0 +1,92 @@
package main
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfigValidationMinMax(t *testing.T) {
config := &RunnerScaleSetListenerConfig{
ConfigureUrl: "github.com/some_org/some_repo",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
MinRunners: 5,
MaxRunners: 2,
Token: "token",
}
err := validateConfig(config)
assert.ErrorContains(t, err, "MinRunners '5' cannot be greater than MaxRunners '2", "Expected error about MinRunners > MaxRunners")
}
func TestConfigValidationMissingToken(t *testing.T) {
config := &RunnerScaleSetListenerConfig{
ConfigureUrl: "github.com/some_org/some_repo",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
}
err := validateConfig(config)
expectedError := fmt.Sprintf("GitHub auth credential is missing, token length: '%d', appId: '%d', installationId: '%d', private key length: '%d", len(config.Token), config.AppID, config.AppInstallationID, len(config.AppPrivateKey))
assert.ErrorContains(t, err, expectedError, "Expected error about missing auth")
}
func TestConfigValidationAppKey(t *testing.T) {
config := &RunnerScaleSetListenerConfig{
AppID: 1,
AppInstallationID: 10,
ConfigureUrl: "github.com/some_org/some_repo",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
}
err := validateConfig(config)
expectedError := fmt.Sprintf("GitHub auth credential is missing, token length: '%d', appId: '%d', installationId: '%d', private key length: '%d", len(config.Token), config.AppID, config.AppInstallationID, len(config.AppPrivateKey))
assert.ErrorContains(t, err, expectedError, "Expected error about missing auth")
}
func TestConfigValidationOnlyOneTypeOfCredentials(t *testing.T) {
config := &RunnerScaleSetListenerConfig{
AppID: 1,
AppInstallationID: 10,
AppPrivateKey: "asdf",
Token: "asdf",
ConfigureUrl: "github.com/some_org/some_repo",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
}
err := validateConfig(config)
expectedError := fmt.Sprintf("only one GitHub auth method supported at a time. Have both PAT and App auth: token length: '%d', appId: '%d', installationId: '%d', private key length: '%d", len(config.Token), config.AppID, config.AppInstallationID, len(config.AppPrivateKey))
assert.ErrorContains(t, err, expectedError, "Expected error about missing auth")
}
func TestConfigValidation(t *testing.T) {
config := &RunnerScaleSetListenerConfig{
ConfigureUrl: "https://github.com/actions",
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
MinRunners: 1,
MaxRunners: 5,
Token: "asdf",
}
err := validateConfig(config)
assert.NoError(t, err, "Expected no error")
}
func TestConfigValidationConfigUrl(t *testing.T) {
config := &RunnerScaleSetListenerConfig{
EphemeralRunnerSetNamespace: "namespace",
EphemeralRunnerSetName: "deployment",
RunnerScaleSetId: 1,
}
err := validateConfig(config)
assert.ErrorContains(t, err, "GitHubConfigUrl is not provided", "Expected error about missing ConfigureUrl")
}

View File

@@ -0,0 +1,13 @@
package main
import (
"context"
"github.com/actions/actions-runner-controller/github/actions"
)
//go:generate mockery --inpackage --name=RunnerScaleSetClient
type RunnerScaleSetClient interface {
GetRunnerScaleSetMessage(ctx context.Context, handler func(msg *actions.RunnerScaleSetMessage) error) error
AcquireJobsForRunnerScaleSet(ctx context.Context, requestIds []int64) error
}

View File

@@ -0,0 +1,57 @@
// Code generated by mockery v2.16.0. DO NOT EDIT.
package main
import (
context "context"
mock "github.com/stretchr/testify/mock"
)
// MockKubernetesManager is an autogenerated mock type for the KubernetesManager type
type MockKubernetesManager struct {
mock.Mock
}
// ScaleEphemeralRunnerSet provides a mock function with given fields: ctx, namespace, resourceName, runnerCount
func (_m *MockKubernetesManager) ScaleEphemeralRunnerSet(ctx context.Context, namespace string, resourceName string, runnerCount int) error {
ret := _m.Called(ctx, namespace, resourceName, runnerCount)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, int) error); ok {
r0 = rf(ctx, namespace, resourceName, runnerCount)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateEphemeralRunnerWithJobInfo provides a mock function with given fields: ctx, namespace, resourceName, ownerName, repositoryName, jobWorkflowRef, jobDisplayName, jobRequestId, workflowRunId
func (_m *MockKubernetesManager) UpdateEphemeralRunnerWithJobInfo(ctx context.Context, namespace string, resourceName string, ownerName string, repositoryName string, jobWorkflowRef string, jobDisplayName string, jobRequestId int64, workflowRunId int64) error {
ret := _m.Called(ctx, namespace, resourceName, ownerName, repositoryName, jobWorkflowRef, jobDisplayName, jobRequestId, workflowRunId)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string, string, string, int64, int64) error); ok {
r0 = rf(ctx, namespace, resourceName, ownerName, repositoryName, jobWorkflowRef, jobDisplayName, jobRequestId, workflowRunId)
} else {
r0 = ret.Error(0)
}
return r0
}
type mockConstructorTestingTNewMockKubernetesManager interface {
mock.TestingT
Cleanup(func())
}
// NewMockKubernetesManager creates a new instance of MockKubernetesManager. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockKubernetesManager(t mockConstructorTestingTNewMockKubernetesManager) *MockKubernetesManager {
mock := &MockKubernetesManager{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,59 @@
// Code generated by mockery v2.16.0. DO NOT EDIT.
package main
import (
context "context"
actions "github.com/actions/actions-runner-controller/github/actions"
mock "github.com/stretchr/testify/mock"
)
// MockRunnerScaleSetClient is an autogenerated mock type for the RunnerScaleSetClient type
type MockRunnerScaleSetClient struct {
mock.Mock
}
// AcquireJobsForRunnerScaleSet provides a mock function with given fields: ctx, requestIds
func (_m *MockRunnerScaleSetClient) AcquireJobsForRunnerScaleSet(ctx context.Context, requestIds []int64) error {
ret := _m.Called(ctx, requestIds)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, []int64) error); ok {
r0 = rf(ctx, requestIds)
} else {
r0 = ret.Error(0)
}
return r0
}
// GetRunnerScaleSetMessage provides a mock function with given fields: ctx, handler
func (_m *MockRunnerScaleSetClient) GetRunnerScaleSetMessage(ctx context.Context, handler func(*actions.RunnerScaleSetMessage) error) error {
ret := _m.Called(ctx, handler)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, func(*actions.RunnerScaleSetMessage) error) error); ok {
r0 = rf(ctx, handler)
} else {
r0 = ret.Error(0)
}
return r0
}
type mockConstructorTestingTNewMockRunnerScaleSetClient interface {
mock.TestingT
Cleanup(func())
}
// NewMockRunnerScaleSetClient creates a new instance of MockRunnerScaleSetClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
func NewMockRunnerScaleSetClient(t mockConstructorTestingTNewMockRunnerScaleSetClient) *MockRunnerScaleSetClient {
mock := &MockRunnerScaleSetClient{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}

View File

@@ -0,0 +1,123 @@
package main
import (
"context"
"fmt"
"time"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/go-logr/logr"
"github.com/pkg/errors"
)
type SessionRefreshingClient struct {
client actions.ActionsService
logger logr.Logger
session *actions.RunnerScaleSetSession
}
func newSessionClient(client actions.ActionsService, logger *logr.Logger, session *actions.RunnerScaleSetSession) *SessionRefreshingClient {
return &SessionRefreshingClient{
client: client,
session: session,
logger: logger.WithName("refreshing_client"),
}
}
func (m *SessionRefreshingClient) GetMessage(ctx context.Context, lastMessageId int64) (*actions.RunnerScaleSetMessage, error) {
message, err := m.client.GetMessage(ctx, m.session.MessageQueueUrl, m.session.MessageQueueAccessToken, lastMessageId)
if err == nil {
return message, nil
}
expiredError := &actions.MessageQueueTokenExpiredError{}
if !errors.As(err, &expiredError) {
return nil, fmt.Errorf("get message failed. %w", err)
}
m.logger.Info("message queue token is expired during GetNextMessage, refreshing...")
session, err := m.client.RefreshMessageSession(ctx, m.session.RunnerScaleSet.Id, m.session.SessionId)
if err != nil {
return nil, fmt.Errorf("refresh message session failed. %w", err)
}
m.session = session
message, err = m.client.GetMessage(ctx, m.session.MessageQueueUrl, m.session.MessageQueueAccessToken, lastMessageId)
if err != nil {
return nil, fmt.Errorf("delete message failed after refresh message session. %w", err)
}
return message, nil
}
func (m *SessionRefreshingClient) DeleteMessage(ctx context.Context, messageId int64) error {
err := m.client.DeleteMessage(ctx, m.session.MessageQueueUrl, m.session.MessageQueueAccessToken, messageId)
if err == nil {
return nil
}
expiredError := &actions.MessageQueueTokenExpiredError{}
if !errors.As(err, &expiredError) {
return fmt.Errorf("delete message failed. %w", err)
}
m.logger.Info("message queue token is expired during DeleteMessage, refreshing...")
session, err := m.client.RefreshMessageSession(ctx, m.session.RunnerScaleSet.Id, m.session.SessionId)
if err != nil {
return fmt.Errorf("refresh message session failed. %w", err)
}
m.session = session
err = m.client.DeleteMessage(ctx, m.session.MessageQueueUrl, m.session.MessageQueueAccessToken, messageId)
if err != nil {
return fmt.Errorf("delete message failed after refresh message session. %w", err)
}
return nil
}
func (m *SessionRefreshingClient) AcquireJobs(ctx context.Context, requestIds []int64) ([]int64, error) {
ids, err := m.client.AcquireJobs(ctx, m.session.RunnerScaleSet.Id, m.session.MessageQueueAccessToken, requestIds)
if err == nil {
return ids, nil
}
expiredError := &actions.MessageQueueTokenExpiredError{}
if !errors.As(err, &expiredError) {
return nil, fmt.Errorf("acquire jobs failed. %w", err)
}
m.logger.Info("message queue token is expired during AcquireJobs, refreshing...")
session, err := m.client.RefreshMessageSession(ctx, m.session.RunnerScaleSet.Id, m.session.SessionId)
if err != nil {
return nil, fmt.Errorf("refresh message session failed. %w", err)
}
m.session = session
ids, err = m.client.AcquireJobs(ctx, m.session.RunnerScaleSet.Id, m.session.MessageQueueAccessToken, requestIds)
if err != nil {
return nil, fmt.Errorf("acquire jobs failed after refresh message session. %w", err)
}
return ids, nil
}
func (m *SessionRefreshingClient) Close() error {
if m.session == nil {
m.logger.Info("session is already deleted. (no-op)")
return nil
}
ctxWithTimeout, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()
m.logger.Info("deleting session.")
err := m.client.DeleteMessageSession(ctxWithTimeout, m.session.RunnerScaleSet.Id, m.session.SessionId)
if err != nil {
return fmt.Errorf("delete message session failed. %w", err)
}
m.session = nil
return nil
}

View File

@@ -0,0 +1,421 @@
package main
import (
"context"
"fmt"
"testing"
"github.com/actions/actions-runner-controller/github/actions"
"github.com/actions/actions-runner-controller/logging"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
func TestGetMessage(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("GetMessage", ctx, session.MessageQueueUrl, session.MessageQueueAccessToken, int64(0)).Return(nil, nil).Once()
mockActionsClient.On("GetMessage", ctx, session.MessageQueueUrl, session.MessageQueueAccessToken, int64(0)).Return(&actions.RunnerScaleSetMessage{MessageId: 1}, nil).Once()
client := newSessionClient(mockActionsClient, &logger, session)
msg, err := client.GetMessage(ctx, 0)
require.NoError(t, err, "GetMessage should not return an error")
assert.Nil(t, msg, "GetMessage should return nil message")
msg, err = client.GetMessage(ctx, 0)
require.NoError(t, err, "GetMessage should not return an error")
assert.Equal(t, int64(1), msg.MessageId, "GetMessage should return a message with id 1")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expected calls to mockActionsClient should have been made")
}
func TestDeleteMessage(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("DeleteMessage", ctx, session.MessageQueueUrl, session.MessageQueueAccessToken, int64(1)).Return(nil).Once()
client := newSessionClient(mockActionsClient, &logger, session)
err := client.DeleteMessage(ctx, int64(1))
assert.NoError(t, err, "DeleteMessage should not return an error")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expected calls to mockActionsClient should have been made")
}
func TestAcquireJobs(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("AcquireJobs", ctx, mock.Anything, "token", mock.MatchedBy(func(ids []int64) bool { return ids[0] == 1 && ids[1] == 2 && ids[2] == 3 })).Return([]int64{1}, nil)
client := newSessionClient(mockActionsClient, &logger, session)
ids, err := client.AcquireJobs(ctx, []int64{1, 2, 3})
assert.NoError(t, err, "AcquireJobs should not return an error")
assert.Equal(t, []int64{1}, ids, "AcquireJobs should return a slice with one id")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expected calls to mockActionsClient should have been made")
}
func TestClose(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("DeleteMessageSession", mock.Anything, 1, &sessionId).Return(nil).Once()
client := newSessionClient(mockActionsClient, &logger, session)
err := client.Close()
assert.NoError(t, err, "DeleteMessageSession should not return an error")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expected calls to mockActionsClient should have been made")
}
func TestGetMessage_Error(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("GetMessage", ctx, session.MessageQueueUrl, session.MessageQueueAccessToken, int64(0)).Return(nil, fmt.Errorf("error")).Once()
client := newSessionClient(mockActionsClient, &logger, session)
msg, err := client.GetMessage(ctx, 0)
assert.ErrorContains(t, err, "get message failed. error", "GetMessage should return an error")
assert.Nil(t, msg, "GetMessage should return nil message")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expected calls to mockActionsClient should have been made")
}
func TestDeleteMessage_SessionError(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("DeleteMessage", ctx, session.MessageQueueUrl, session.MessageQueueAccessToken, int64(1)).Return(fmt.Errorf("error")).Once()
client := newSessionClient(mockActionsClient, &logger, session)
err := client.DeleteMessage(ctx, int64(1))
assert.ErrorContains(t, err, "delete message failed. error", "DeleteMessage should return an error")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expected calls to mockActionsClient should have been made")
}
func TestAcquireJobs_Error(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("AcquireJobs", ctx, mock.Anything, "token", mock.MatchedBy(func(ids []int64) bool { return ids[0] == 1 && ids[1] == 2 && ids[2] == 3 })).Return(nil, fmt.Errorf("error")).Once()
client := newSessionClient(mockActionsClient, &logger, session)
ids, err := client.AcquireJobs(ctx, []int64{1, 2, 3})
assert.ErrorContains(t, err, "acquire jobs failed. error", "AcquireJobs should return an error")
assert.Nil(t, ids, "AcquireJobs should return nil ids")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expected calls to mockActionsClient should have been made")
}
func TestGetMessage_RefreshToken(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("GetMessage", ctx, session.MessageQueueUrl, session.MessageQueueAccessToken, int64(0)).Return(nil, &actions.MessageQueueTokenExpiredError{}).Once()
mockActionsClient.On("GetMessage", ctx, session.MessageQueueUrl, "token2", int64(0)).Return(&actions.RunnerScaleSetMessage{
MessageId: 1,
MessageType: "test",
Body: "test",
}, nil).Once()
mockActionsClient.On("RefreshMessageSession", ctx, session.RunnerScaleSet.Id, session.SessionId).Return(&actions.RunnerScaleSetSession{
SessionId: &sessionId,
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token2",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}, nil).Once()
client := newSessionClient(mockActionsClient, &logger, session)
msg, err := client.GetMessage(ctx, 0)
assert.NoError(t, err, "Error getting message")
assert.Equal(t, int64(1), msg.MessageId, "message id should be updated")
assert.Equal(t, "token2", client.session.MessageQueueAccessToken, "Message queue access token should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestDeleteMessage_RefreshSessionToken(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("DeleteMessage", ctx, session.MessageQueueUrl, session.MessageQueueAccessToken, int64(1)).Return(&actions.MessageQueueTokenExpiredError{}).Once()
mockActionsClient.On("DeleteMessage", ctx, session.MessageQueueUrl, "token2", int64(1)).Return(nil).Once()
mockActionsClient.On("RefreshMessageSession", ctx, session.RunnerScaleSet.Id, session.SessionId).Return(&actions.RunnerScaleSetSession{
SessionId: &sessionId,
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token2",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}, nil)
client := newSessionClient(mockActionsClient, &logger, session)
err := client.DeleteMessage(ctx, 1)
assert.NoError(t, err, "Error delete message")
assert.Equal(t, "token2", client.session.MessageQueueAccessToken, "Message queue access token should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestAcquireJobs_RefreshToken(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("AcquireJobs", ctx, mock.Anything, session.MessageQueueAccessToken, mock.MatchedBy(func(ids []int64) bool { return ids[0] == 1 && ids[1] == 2 && ids[2] == 3 })).Return(nil, &actions.MessageQueueTokenExpiredError{}).Once()
mockActionsClient.On("AcquireJobs", ctx, mock.Anything, "token2", mock.MatchedBy(func(ids []int64) bool { return ids[0] == 1 && ids[1] == 2 && ids[2] == 3 })).Return([]int64{1, 2, 3}, nil)
mockActionsClient.On("RefreshMessageSession", ctx, session.RunnerScaleSet.Id, session.SessionId).Return(&actions.RunnerScaleSetSession{
SessionId: &sessionId,
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token2",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}, nil)
client := newSessionClient(mockActionsClient, &logger, session)
ids, err := client.AcquireJobs(ctx, []int64{1, 2, 3})
assert.NoError(t, err, "Error acquiring jobs")
assert.Equal(t, []int64{1, 2, 3}, ids, "Job ids should be returned")
assert.Equal(t, "token2", client.session.MessageQueueAccessToken, "Message queue access token should be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestGetMessage_RefreshToken_Failed(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("GetMessage", ctx, session.MessageQueueUrl, session.MessageQueueAccessToken, int64(0)).Return(nil, &actions.MessageQueueTokenExpiredError{}).Once()
mockActionsClient.On("RefreshMessageSession", ctx, session.RunnerScaleSet.Id, session.SessionId).Return(nil, fmt.Errorf("error"))
client := newSessionClient(mockActionsClient, &logger, session)
msg, err := client.GetMessage(ctx, 0)
assert.ErrorContains(t, err, "refresh message session failed. error", "Error should be returned")
assert.Nil(t, msg, "Message should be nil")
assert.Equal(t, "token", client.session.MessageQueueAccessToken, "Message queue access token should not be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestDeleteMessage_RefreshToken_Failed(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("DeleteMessage", ctx, session.MessageQueueUrl, session.MessageQueueAccessToken, int64(1)).Return(&actions.MessageQueueTokenExpiredError{}).Once()
mockActionsClient.On("RefreshMessageSession", ctx, session.RunnerScaleSet.Id, session.SessionId).Return(nil, fmt.Errorf("error"))
client := newSessionClient(mockActionsClient, &logger, session)
err := client.DeleteMessage(ctx, 1)
assert.ErrorContains(t, err, "refresh message session failed. error", "Error getting message")
assert.Equal(t, "token", client.session.MessageQueueAccessToken, "Message queue access token should not be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestAcquireJobs_RefreshToken_Failed(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
ctx := context.Background()
sessionId := uuid.New()
session := &actions.RunnerScaleSetSession{
SessionId: &sessionId,
OwnerName: "owner",
MessageQueueUrl: "https://github.com",
MessageQueueAccessToken: "token",
RunnerScaleSet: &actions.RunnerScaleSet{
Id: 1,
},
}
mockActionsClient.On("AcquireJobs", ctx, mock.Anything, session.MessageQueueAccessToken, mock.MatchedBy(func(ids []int64) bool { return ids[0] == 1 && ids[1] == 2 && ids[2] == 3 })).Return(nil, &actions.MessageQueueTokenExpiredError{}).Once()
mockActionsClient.On("RefreshMessageSession", ctx, session.RunnerScaleSet.Id, session.SessionId).Return(nil, fmt.Errorf("error"))
client := newSessionClient(mockActionsClient, &logger, session)
ids, err := client.AcquireJobs(ctx, []int64{1, 2, 3})
assert.ErrorContains(t, err, "refresh message session failed. error", "Expect error refreshing message session")
assert.Nil(t, ids, "Job ids should be nil")
assert.Equal(t, "token", client.session.MessageQueueAccessToken, "Message queue access token should not be updated")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}
func TestClose_Skip(t *testing.T) {
mockActionsClient := &actions.MockActionsService{}
logger, log_err := logging.NewLogger(logging.LogLevelDebug, logging.LogFormatText)
logger = logger.WithName(t.Name())
require.NoError(t, log_err, "Error creating logger")
client := newSessionClient(mockActionsClient, &logger, nil)
err := client.Close()
require.NoError(t, err, "Error closing session client")
assert.True(t, mockActionsClient.AssertExpectations(t), "All expectations should be met")
}