mirror of
https://github.com/actions/actions-runner-controller.git
synced 2025-12-10 19:50:30 +00:00
Enhances #57 to add support for organizational runners. As GitHub Actions does not have an appropriate API for this, this is the spec you need: ``` apiVersion: actions.summerwind.dev/v1alpha1 kind: RunnerDeployment metadata: name: myrunners spec: minReplicas: 1 maxReplicas: 3 autoscaling: metrics: - type: TotalNumberOfQueuedAndProgressingWorkflowRuns repositories: # Assumes that you have `github.com/myorg/myrepo1` repo - myrepo1 - myrepo2 template: spec: organization: myorg ``` It works by collecting "in_progress" and "queued" workflow runs for the repositories `myrepo1` and `myrepo2` to autoscale the number of replicas, assuming you have this organizational runner deployment only for those two repositories. For example, if `myrepo1` had 1 `in_progress` and 2 `queued` workflow runs, and `myrepo2` had 4 `in_progress` and 8 `queued` workflow runs at the time of running the reconcilation loop on the runner deployment, it will scale replicas to 1 + 2 + 4 + 8 = 15. Perhaps we might be better add a kind of "ratio" setting so that you can configure the controller to create e.g. 2x runners than demanded. But that's another story. Ref #10
372 lines
10 KiB
Go
372 lines
10 KiB
Go
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"github.com/summerwind/actions-runner-controller/api/v1alpha1"
|
|
"github.com/summerwind/actions-runner-controller/github"
|
|
"github.com/summerwind/actions-runner-controller/github/fake"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
|
"testing"
|
|
)
|
|
|
|
func newGithubClient(server *httptest.Server) *github.Client {
|
|
client, err := github.NewClientWithAccessToken("token")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
|
|
baseURL, err := url.Parse(server.URL + "/")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
client.Client.BaseURL = baseURL
|
|
|
|
return client
|
|
}
|
|
|
|
func TestDetermineDesiredReplicas_RepositoryRunner(t *testing.T) {
|
|
intPtr := func(v int) *int {
|
|
return &v
|
|
}
|
|
|
|
metav1Now := metav1.Now()
|
|
testcases := []struct {
|
|
repo string
|
|
org string
|
|
fixed *int
|
|
max *int
|
|
min *int
|
|
sReplicas *int
|
|
sTime *metav1.Time
|
|
workflowRuns string
|
|
want int
|
|
err string
|
|
}{
|
|
// 3 demanded, max at 3
|
|
{
|
|
repo: "test/valid",
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 4, "workflow_runs":[{"status":"queued"}, {"status":"in_progress"}, {"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 3,
|
|
},
|
|
// 2 demanded, max at 3, currently 3, delay scaling down due to grace period
|
|
{
|
|
repo: "test/valid",
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
sReplicas: intPtr(3),
|
|
sTime: &metav1Now,
|
|
workflowRuns: `{"total_count": 4, "workflow_runs":[{"status":"queued"}, {"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 3,
|
|
},
|
|
// 3 demanded, max at 2
|
|
{
|
|
repo: "test/valid",
|
|
min: intPtr(2),
|
|
max: intPtr(2),
|
|
workflowRuns: `{"total_count": 4, "workflow_runs":[{"status":"queued"}, {"status":"in_progress"}, {"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 2,
|
|
},
|
|
// 2 demanded, min at 2
|
|
{
|
|
repo: "test/valid",
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 3, "workflow_runs":[{"status":"queued"}, {"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 2,
|
|
},
|
|
// 1 demanded, min at 2
|
|
{
|
|
repo: "test/valid",
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 2, "workflow_runs":[{"status":"queued"}, {"status":"completed"}]}"`,
|
|
want: 2,
|
|
},
|
|
// 1 demanded, min at 2
|
|
{
|
|
repo: "test/valid",
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 2, "workflow_runs":[{"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 2,
|
|
},
|
|
// 1 demanded, min at 1
|
|
{
|
|
repo: "test/valid",
|
|
min: intPtr(1),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 2, "workflow_runs":[{"status":"queued"}, {"status":"completed"}]}"`,
|
|
want: 1,
|
|
},
|
|
// 1 demanded, min at 1
|
|
{
|
|
repo: "test/valid",
|
|
min: intPtr(1),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 2, "workflow_runs":[{"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 1,
|
|
},
|
|
// fixed at 3
|
|
{
|
|
repo: "test/valid",
|
|
fixed: intPtr(3),
|
|
want: 3,
|
|
},
|
|
}
|
|
|
|
for i := range testcases {
|
|
tc := testcases[i]
|
|
|
|
log := zap.New(func(o *zap.Options) {
|
|
o.Development = true
|
|
})
|
|
|
|
scheme := runtime.NewScheme()
|
|
_ = clientgoscheme.AddToScheme(scheme)
|
|
_ = v1alpha1.AddToScheme(scheme)
|
|
|
|
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
|
|
server := fake.NewServer(fake.WithListRepositoryWorkflowRunsResponse(200, tc.workflowRuns))
|
|
defer server.Close()
|
|
client := newGithubClient(server)
|
|
|
|
r := &RunnerDeploymentReconciler{
|
|
Log: log,
|
|
GitHubClient: client,
|
|
Scheme: scheme,
|
|
}
|
|
|
|
rd := v1alpha1.RunnerDeployment{
|
|
TypeMeta: metav1.TypeMeta{},
|
|
Spec: v1alpha1.RunnerDeploymentSpec{
|
|
Template: v1alpha1.RunnerTemplate{
|
|
Spec: v1alpha1.RunnerSpec{
|
|
Repository: tc.repo,
|
|
},
|
|
},
|
|
Replicas: tc.fixed,
|
|
MaxReplicas: tc.max,
|
|
MinReplicas: tc.min,
|
|
},
|
|
Status: v1alpha1.RunnerDeploymentStatus{
|
|
Replicas: tc.sReplicas,
|
|
LastSuccessfulScaleOutTime: tc.sTime,
|
|
},
|
|
}
|
|
|
|
rs, err := r.newRunnerReplicaSetWithAutoscaling(rd)
|
|
if err != nil {
|
|
if tc.err == "" {
|
|
t.Fatalf("unexpected error: expected none, got %v", err)
|
|
} else if err.Error() != tc.err {
|
|
t.Fatalf("unexpected error: expected %v, got %v", tc.err, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
got := rs.Spec.Replicas
|
|
|
|
if got == nil {
|
|
t.Fatalf("unexpected value of rs.Spec.Replicas: nil")
|
|
}
|
|
|
|
if *got != tc.want {
|
|
t.Errorf("%d: incorrect desired replicas: want %d, got %d", i, tc.want, *got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDetermineDesiredReplicas_OrganizationalRunner(t *testing.T) {
|
|
intPtr := func(v int) *int {
|
|
return &v
|
|
}
|
|
|
|
metav1Now := metav1.Now()
|
|
testcases := []struct {
|
|
repos []string
|
|
org string
|
|
fixed *int
|
|
max *int
|
|
min *int
|
|
sReplicas *int
|
|
sTime *metav1.Time
|
|
workflowRuns string
|
|
want int
|
|
err string
|
|
}{
|
|
// 3 demanded, max at 3
|
|
{
|
|
org: "test",
|
|
repos: []string{"valid"},
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 4, "workflow_runs":[{"status":"queued"}, {"status":"in_progress"}, {"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 3,
|
|
},
|
|
// 2 demanded, max at 3, currently 3, delay scaling down due to grace period
|
|
{
|
|
org: "test",
|
|
repos: []string{"valid"},
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
sReplicas: intPtr(3),
|
|
sTime: &metav1Now,
|
|
workflowRuns: `{"total_count": 4, "workflow_runs":[{"status":"queued"}, {"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 3,
|
|
},
|
|
// 3 demanded, max at 2
|
|
{
|
|
org: "test",
|
|
repos: []string{"valid"},
|
|
min: intPtr(2),
|
|
max: intPtr(2),
|
|
workflowRuns: `{"total_count": 4, "workflow_runs":[{"status":"queued"}, {"status":"in_progress"}, {"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 2,
|
|
},
|
|
// 2 demanded, min at 2
|
|
{
|
|
org: "test",
|
|
repos: []string{"valid"},
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 3, "workflow_runs":[{"status":"queued"}, {"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 2,
|
|
},
|
|
// 1 demanded, min at 2
|
|
{
|
|
org: "test",
|
|
repos: []string{"valid"},
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 2, "workflow_runs":[{"status":"queued"}, {"status":"completed"}]}"`,
|
|
want: 2,
|
|
},
|
|
// 1 demanded, min at 2
|
|
{
|
|
org: "test",
|
|
repos: []string{"valid"},
|
|
min: intPtr(2),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 2, "workflow_runs":[{"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 2,
|
|
},
|
|
// 1 demanded, min at 1
|
|
{
|
|
org: "test",
|
|
repos: []string{"valid"},
|
|
min: intPtr(1),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 2, "workflow_runs":[{"status":"queued"}, {"status":"completed"}]}"`,
|
|
want: 1,
|
|
},
|
|
// 1 demanded, min at 1
|
|
{
|
|
org: "test",
|
|
repos: []string{"valid"},
|
|
min: intPtr(1),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 2, "workflow_runs":[{"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
want: 1,
|
|
},
|
|
// fixed at 3
|
|
{
|
|
org: "test",
|
|
repos: []string{"valid"},
|
|
fixed: intPtr(3),
|
|
want: 3,
|
|
},
|
|
// org runner, fixed at 3
|
|
{
|
|
org: "test",
|
|
fixed: intPtr(3),
|
|
want: 3,
|
|
},
|
|
// org runner, 1 demanded, min at 1, no repos
|
|
{
|
|
org: "test",
|
|
min: intPtr(1),
|
|
max: intPtr(3),
|
|
workflowRuns: `{"total_count": 2, "workflow_runs":[{"status":"in_progress"}, {"status":"completed"}]}"`,
|
|
err: "validating autoscaling metrics: spec.autoscaling.metrics[].repositoryNames is required and must have one more more entries for organizational runner deployment",
|
|
},
|
|
}
|
|
|
|
for i := range testcases {
|
|
tc := testcases[i]
|
|
|
|
log := zap.New(func(o *zap.Options) {
|
|
o.Development = true
|
|
})
|
|
|
|
scheme := runtime.NewScheme()
|
|
_ = clientgoscheme.AddToScheme(scheme)
|
|
_ = v1alpha1.AddToScheme(scheme)
|
|
|
|
t.Run(fmt.Sprintf("case %d", i), func(t *testing.T) {
|
|
server := fake.NewServer(fake.WithListRepositoryWorkflowRunsResponse(200, tc.workflowRuns))
|
|
defer server.Close()
|
|
client := newGithubClient(server)
|
|
|
|
r := &RunnerDeploymentReconciler{
|
|
Log: log,
|
|
GitHubClient: client,
|
|
Scheme: scheme,
|
|
}
|
|
|
|
rd := v1alpha1.RunnerDeployment{
|
|
TypeMeta: metav1.TypeMeta{},
|
|
Spec: v1alpha1.RunnerDeploymentSpec{
|
|
Template: v1alpha1.RunnerTemplate{
|
|
Spec: v1alpha1.RunnerSpec{
|
|
Organization: tc.org,
|
|
},
|
|
},
|
|
Autoscaling: v1alpha1.AutoscalingSpec{
|
|
Metrics: []v1alpha1.MetricSpec{
|
|
{
|
|
Type: v1alpha1.AutoscalingMetricTypeTotalNumberOfQueuedAndProgressingWorkflowRuns,
|
|
RepositoryNames: tc.repos,
|
|
},
|
|
},
|
|
},
|
|
Replicas: tc.fixed,
|
|
MaxReplicas: tc.max,
|
|
MinReplicas: tc.min,
|
|
},
|
|
Status: v1alpha1.RunnerDeploymentStatus{
|
|
Replicas: tc.sReplicas,
|
|
LastSuccessfulScaleOutTime: tc.sTime,
|
|
},
|
|
}
|
|
|
|
rs, err := r.newRunnerReplicaSetWithAutoscaling(rd)
|
|
if err != nil {
|
|
if tc.err == "" {
|
|
t.Fatalf("unexpected error: expected none, got %v", err)
|
|
} else if err.Error() != tc.err {
|
|
t.Fatalf("unexpected error: expected %v, got %v", tc.err, err)
|
|
}
|
|
return
|
|
}
|
|
|
|
got := rs.Spec.Replicas
|
|
|
|
if got == nil {
|
|
t.Fatalf("unexpected value of rs.Spec.Replicas: nil")
|
|
}
|
|
|
|
if *got != tc.want {
|
|
t.Errorf("%d: incorrect desired replicas: want %d, got %d", i, tc.want, *got)
|
|
}
|
|
})
|
|
}
|
|
}
|