mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-09-30 15:53:53 +02:00
296 lines
12 KiB
Go
296 lines
12 KiB
Go
package actionsgithubcom
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
corev1 "k8s.io/api/core/v1"
|
|
kerrors "k8s.io/apimachinery/pkg/api/errors"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
ctrl "sigs.k8s.io/controller-runtime"
|
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
|
ctrlfake "sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
|
)
|
|
|
|
func terminatedRunnerPod(exitCode int32) *corev1.Pod {
|
|
return &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-runner", Namespace: "default", UID: "d6f1e2b4-0f3a-4a1e-9a1f-2c9f0a3b7c55"},
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{{Name: v1alpha1.EphemeralRunnerContainerName}},
|
|
},
|
|
Status: corev1.PodStatus{
|
|
Phase: corev1.PodRunning,
|
|
ContainerStatuses: []corev1.ContainerStatus{
|
|
{
|
|
Name: v1alpha1.EphemeralRunnerContainerName,
|
|
State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: exitCode}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestDeletePodOptionsSkipsGracePeriodOnlyWhenNothingIsRunning pins which pods
|
|
// are deleted without a grace period.
|
|
//
|
|
// A pod that is done holds its name, its place on the node and its share of any
|
|
// ResourceQuota until the API server stops waiting on the kubelet, which is
|
|
// what leaves a burst of finished jobs sitting in Terminating while the runners
|
|
// replacing them have nowhere to start. Skipping the wait is only safe once
|
|
// every container has stopped, so the cases below cover both the pod phase and
|
|
// the container states the phase lags behind, including the sidecars that
|
|
// outlive the runner container and the lost node whose phase says the pod
|
|
// failed while the kubelet's last word was that the runner is still up.
|
|
func TestDeletePodOptionsSkipsGracePeriodOnlyWhenNothingIsRunning(t *testing.T) {
|
|
sidecarRunning := terminatedRunnerPod(0)
|
|
sidecarRunning.Spec.Containers = append(sidecarRunning.Spec.Containers, corev1.Container{Name: "dind"})
|
|
sidecarRunning.Status.ContainerStatuses = append(sidecarRunning.Status.ContainerStatuses, corev1.ContainerStatus{
|
|
Name: "dind",
|
|
State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}},
|
|
})
|
|
|
|
nativeSidecarRunning := terminatedRunnerPod(0)
|
|
nativeSidecarRunning.Status.InitContainerStatuses = []corev1.ContainerStatus{
|
|
{Name: "dind", State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}},
|
|
}
|
|
|
|
statusNotReportedYet := terminatedRunnerPod(0)
|
|
statusNotReportedYet.Spec.Containers = append(statusNotReportedYet.Spec.Containers, corev1.Container{Name: "dind"})
|
|
|
|
runnerStillRunning := terminatedRunnerPod(0)
|
|
runnerStillRunning.Status.ContainerStatuses[0].State = corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}
|
|
|
|
succeeded := terminatedRunnerPod(0)
|
|
succeeded.Status.Phase = corev1.PodSucceeded
|
|
|
|
failed := terminatedRunnerPod(1)
|
|
failed.Status.Phase = corev1.PodFailed
|
|
|
|
nodeLost := terminatedRunnerPod(0)
|
|
nodeLost.Status.Phase = corev1.PodFailed
|
|
nodeLost.Status.ContainerStatuses[0].State = corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}
|
|
|
|
failedBeforeStarting := terminatedRunnerPod(0)
|
|
failedBeforeStarting.Status.Phase = corev1.PodFailed
|
|
failedBeforeStarting.Status.ContainerStatuses = nil
|
|
failedBeforeStarting.Status.InitContainerStatuses = []corev1.ContainerStatus{
|
|
{Name: "init", State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 1}}},
|
|
}
|
|
|
|
tt := map[string]struct {
|
|
pod *corev1.Pod
|
|
immediate bool
|
|
}{
|
|
"pod succeeded": {pod: succeeded, immediate: true},
|
|
"pod failed": {pod: failed, immediate: true},
|
|
"runner exited but the phase has not moved": {pod: terminatedRunnerPod(0), immediate: true},
|
|
"pod failed before its containers started": {pod: failedBeforeStarting, immediate: true},
|
|
"runner is still running": {pod: runnerStillRunning},
|
|
"sidecar is still running": {pod: sidecarRunning},
|
|
"native sidecar is still running": {pod: nativeSidecarRunning},
|
|
"a container has not reported a status yet": {pod: statusNotReportedYet},
|
|
"the phase failed the pod but a container is still reported as running": {pod: nodeLost},
|
|
}
|
|
|
|
for name, tc := range tt {
|
|
t.Run(name, func(t *testing.T) {
|
|
r := &EphemeralRunnerReconciler{}
|
|
opts := r.deletePodOptions(tc.pod)
|
|
|
|
var deleteOptions client.DeleteOptions
|
|
for _, opt := range opts {
|
|
opt.ApplyToDelete(&deleteOptions)
|
|
}
|
|
|
|
if !tc.immediate {
|
|
assert.Empty(t, opts)
|
|
assert.Nil(t, deleteOptions.GracePeriodSeconds)
|
|
return
|
|
}
|
|
|
|
require.NotNil(t, deleteOptions.GracePeriodSeconds)
|
|
assert.Equal(t, int64(0), *deleteOptions.GracePeriodSeconds)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDeletePodOptionsHonorsTheConfiguredGracePeriod pins the escape hatch for
|
|
// clusters that want finished pods to stick around.
|
|
//
|
|
// Removing a pod the moment its job is over is what hands the cluster back to
|
|
// the runners waiting for it, but it also takes the pod away from anything that
|
|
// reads it afterwards, such as a log collector scraping from the API. The grace
|
|
// period is configurable for those, and a negative value hands the decision
|
|
// back to the pod, which is how the deletion behaved before it was skipped.
|
|
func TestDeletePodOptionsHonorsTheConfiguredGracePeriod(t *testing.T) {
|
|
pod := terminatedRunnerPod(0)
|
|
|
|
t.Run("a configured grace period is applied to a finished pod", func(t *testing.T) {
|
|
r := &EphemeralRunnerReconciler{TerminatedPodGracePeriodSeconds: 30}
|
|
|
|
var deleteOptions client.DeleteOptions
|
|
for _, opt := range r.deletePodOptions(pod) {
|
|
opt.ApplyToDelete(&deleteOptions)
|
|
}
|
|
|
|
require.NotNil(t, deleteOptions.GracePeriodSeconds)
|
|
assert.Equal(t, int64(30), *deleteOptions.GracePeriodSeconds)
|
|
})
|
|
|
|
t.Run("a negative grace period leaves the deletion alone", func(t *testing.T) {
|
|
r := &EphemeralRunnerReconciler{TerminatedPodGracePeriodSeconds: -1}
|
|
|
|
assert.Empty(t, r.deletePodOptions(pod))
|
|
})
|
|
}
|
|
|
|
// TestDeletePodOptionsDeletesOnlyThePodItLookedAt pins that the force delete
|
|
// names the pod it was handed, not the name that pod happens to hold.
|
|
//
|
|
// Every generation of a runner's pod is named after the EphemeralRunner, and
|
|
// pods are read through the informer cache, so a delete by name can outlive the
|
|
// object it was decided on: an outgoing leader whose reconcile is still running
|
|
// after the lease moved would otherwise delete the replacement pod the new
|
|
// leader has already started a job in. The UID makes that a conflict instead.
|
|
// This has to be asserted on the options, because the fake client honours only
|
|
// the ResourceVersion precondition and would delete the pod either way.
|
|
func TestDeletePodOptionsDeletesOnlyThePodItLookedAt(t *testing.T) {
|
|
r := &EphemeralRunnerReconciler{}
|
|
|
|
t.Run("a finished pod is deleted by identity", func(t *testing.T) {
|
|
pod := terminatedRunnerPod(0)
|
|
|
|
var deleteOptions client.DeleteOptions
|
|
for _, opt := range r.deletePodOptions(pod) {
|
|
opt.ApplyToDelete(&deleteOptions)
|
|
}
|
|
|
|
require.NotNil(t, deleteOptions.Preconditions)
|
|
require.NotNil(t, deleteOptions.Preconditions.UID)
|
|
assert.Equal(t, pod.UID, *deleteOptions.Preconditions.UID)
|
|
})
|
|
|
|
t.Run("a running pod is deleted without any options", func(t *testing.T) {
|
|
pod := terminatedRunnerPod(0)
|
|
pod.Status.ContainerStatuses[0].State = corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}
|
|
|
|
assert.Empty(t, r.deletePodOptions(pod))
|
|
})
|
|
|
|
t.Run("a pod with no UID carries no precondition", func(t *testing.T) {
|
|
pod := terminatedRunnerPod(0)
|
|
pod.UID = ""
|
|
|
|
var deleteOptions client.DeleteOptions
|
|
for _, opt := range r.deletePodOptions(pod) {
|
|
opt.ApplyToDelete(&deleteOptions)
|
|
}
|
|
|
|
require.NotNil(t, deleteOptions.GracePeriodSeconds)
|
|
assert.Nil(t, deleteOptions.Preconditions, "an empty UID is a precondition nothing can satisfy")
|
|
})
|
|
}
|
|
|
|
// TestReconcileReleasesTheRunnerPodWithoutAGracePeriod pins how the pod of a
|
|
// finished runner goes away.
|
|
//
|
|
// The reconcile that observes the clean exit marks the runner Succeeded and
|
|
// deletes it; the finalizers hold the object until the deletion reconcile,
|
|
// which is what deletes the pod. What matters is the state that run of
|
|
// reconciles leaves behind: the runner deleted, and its pod gone rather than
|
|
// lingering in Terminating for the full terminationGracePeriodSeconds with
|
|
// nothing left inside it to shut down.
|
|
//
|
|
// The pod is deliberately not deleted on the success path itself. Doing that
|
|
// emits a pod deletion on the pod informer microseconds after the status patch
|
|
// goes to the runner informer, and the two streams have no ordering between
|
|
// them, so the reconcile that deletion wakes can read a runner that is not yet
|
|
// Succeeded and build a replacement pod from a JIT config that has already been
|
|
// used.
|
|
func TestReconcileReleasesTheRunnerPodWithoutAGracePeriod(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
require.NoError(t, corev1.AddToScheme(scheme))
|
|
require.NoError(t, v1alpha1.AddToScheme(scheme))
|
|
|
|
runner := &v1alpha1.EphemeralRunner{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-runner",
|
|
Namespace: "default",
|
|
Finalizers: []string{ephemeralRunnerFinalizerName, ephemeralRunnerActionsFinalizerName},
|
|
},
|
|
Spec: v1alpha1.EphemeralRunnerSpec{
|
|
GitHubConfigURL: "https://github.com/org/repo",
|
|
PodTemplateSpec: corev1.PodTemplateSpec{
|
|
Spec: corev1.PodSpec{
|
|
Containers: []corev1.Container{{Name: v1alpha1.EphemeralRunnerContainerName}},
|
|
},
|
|
},
|
|
},
|
|
Status: v1alpha1.EphemeralRunnerStatus{
|
|
Phase: v1alpha1.EphemeralRunnerPhaseRunning,
|
|
RunnerID: 42,
|
|
RunnerName: "test-runner",
|
|
},
|
|
}
|
|
|
|
secret := &corev1.Secret{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-runner", Namespace: "default"},
|
|
Data: map[string][]byte{"runnerId": []byte("42"), "runnerName": []byte("test-runner")},
|
|
}
|
|
|
|
var podDeleteOptions []client.DeleteOptions
|
|
c := ctrlfake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(runner, secret, terminatedRunnerPod(0)).
|
|
WithStatusSubresource(&v1alpha1.EphemeralRunner{}).
|
|
WithInterceptorFuncs(interceptor.Funcs{
|
|
Delete: func(ctx context.Context, c client.WithWatch, obj client.Object, opts ...client.DeleteOption) error {
|
|
if _, ok := obj.(*corev1.Pod); ok {
|
|
var applied client.DeleteOptions
|
|
for _, opt := range opts {
|
|
opt.ApplyToDelete(&applied)
|
|
}
|
|
podDeleteOptions = append(podDeleteOptions, applied)
|
|
}
|
|
return c.Delete(ctx, obj, opts...)
|
|
},
|
|
}).
|
|
Build()
|
|
|
|
reconciler := &EphemeralRunnerReconciler{
|
|
Client: c,
|
|
Scheme: scheme,
|
|
ResourceBuilder: ResourceBuilder{ResourceCache: newTestResourceCache()},
|
|
}
|
|
|
|
key := types.NamespacedName{Namespace: "default", Name: "test-runner"}
|
|
|
|
// The reconcile that sees the exit records it and deletes the runner. The
|
|
// pod is still the deletion reconcile's to clean up.
|
|
_, err := reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: key})
|
|
require.NoError(t, err)
|
|
|
|
var got v1alpha1.EphemeralRunner
|
|
require.NoError(t, c.Get(t.Context(), key, &got), "the runner is deleted, but its finalizers keep it until the deletion reconcile runs")
|
|
assert.False(t, got.DeletionTimestamp.IsZero(), "the runner must be deleted by the reconcile that observes the exit")
|
|
assert.Equal(t, v1alpha1.EphemeralRunnerPhaseSucceeded, got.Status.Phase)
|
|
assert.Empty(t, podDeleteOptions, "the success path must not delete the pod itself")
|
|
|
|
// The deletion reconcile is what hands the pod back.
|
|
_, err = reconciler.Reconcile(t.Context(), ctrl.Request{NamespacedName: key})
|
|
require.NoError(t, err)
|
|
|
|
require.Len(t, podDeleteOptions, 1, "the pod of a finished runner must be deleted once it is being finalized")
|
|
require.NotNil(t, podDeleteOptions[0].GracePeriodSeconds)
|
|
assert.Equal(t, int64(0), *podDeleteOptions[0].GracePeriodSeconds, "a pod with nothing left running in it must not wait out a grace period")
|
|
|
|
err = c.Get(t.Context(), key, new(corev1.Pod))
|
|
assert.True(t, kerrors.IsNotFound(err), "the pod must be gone, got %v", err)
|
|
}
|