mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-09-30 16:11:06 +02:00
An EphemeralRunnerSet goes Outdated when a child runner reports that the
Actions service rejected its runner spec, and the AutoscalingRunnerSet
then tears the listener down so the scale set stops taking jobs. Until
now nothing recorded which runner spec a given Outdated report was about,
so a report from a runner that predates a spec update kept the set
switched off even after the user had already fixed the spec.
Runners now carry the EphemeralRunnerSet's actionable revision as an
annotation, and Outdated runners are split into two groups:
- stale outdated: created before the currently applied revision. Their
verdict is about a spec that no longer exists, so they are deleted
and replaced by runners built from the current spec.
- outdated: created at or after the applied revision. Their verdict is
about the current spec, so they hold the set in the Outdated phase.
They are deliberately not delete-and-replaced: a fresh runner at the
same revision would report Outdated again, forever.
patchAppliedActionableRevisionStatus now recomputes the phase in both
directions against the revision being applied, because it returns from
Reconcile without reaching updateStatus. The AutoscalingRunnerSet
teardown guard additionally requires the applied revision to have caught
up with the spec revision, so it does not act on an Outdated verdict for
a spec that has already been replaced.
terminated() now allocates a fresh slice instead of appending into the
finished slice, which could alias its backing array.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
218 lines
7.2 KiB
Go
218 lines
7.2 KiB
Go
package actionsgithubcom
|
|
|
|
import (
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
|
|
"github.com/stretchr/testify/assert"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
)
|
|
|
|
func outdatedRunnerAtRevision(name string, revision int64) v1alpha1.EphemeralRunner {
|
|
return v1alpha1.EphemeralRunner{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: name,
|
|
Annotations: map[string]string{
|
|
AnnotationKeyActionableRevision: strconv.FormatInt(revision, 10),
|
|
},
|
|
},
|
|
Status: v1alpha1.EphemeralRunnerStatus{
|
|
Phase: v1alpha1.EphemeralRunnerPhaseOutdated,
|
|
},
|
|
}
|
|
}
|
|
|
|
// TestNewEphemeralRunnersByStates_OutdatedIsRevisionScoped covers the core of the
|
|
// outdated-recovery behaviour: a runner that reported Outdated against a runner
|
|
// spec that has since been replaced must not be treated as evidence about the
|
|
// current spec.
|
|
func TestNewEphemeralRunnersByStates_OutdatedIsRevisionScoped(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
runners []v1alpha1.EphemeralRunner
|
|
appliedRevision int64
|
|
wantOutdatedNames []string
|
|
wantStaleOutdatedName []string
|
|
}{
|
|
{
|
|
name: "runner at the applied revision is genuinely outdated",
|
|
runners: []v1alpha1.EphemeralRunner{outdatedRunnerAtRevision("current", 3)},
|
|
appliedRevision: 3,
|
|
wantOutdatedNames: []string{"current"},
|
|
},
|
|
{
|
|
name: "runner from before the last spec update is stale",
|
|
runners: []v1alpha1.EphemeralRunner{outdatedRunnerAtRevision("old", 2)},
|
|
appliedRevision: 3,
|
|
wantStaleOutdatedName: []string{"old"},
|
|
},
|
|
{
|
|
name: "mixed revisions are split",
|
|
runners: []v1alpha1.EphemeralRunner{
|
|
outdatedRunnerAtRevision("old", 1),
|
|
outdatedRunnerAtRevision("current", 4),
|
|
},
|
|
appliedRevision: 4,
|
|
wantOutdatedNames: []string{"current"},
|
|
wantStaleOutdatedName: []string{"old"},
|
|
},
|
|
{
|
|
name: "runner without the annotation is treated as revision 0",
|
|
runners: []v1alpha1.EphemeralRunner{{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "legacy"},
|
|
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseOutdated},
|
|
}},
|
|
appliedRevision: 0,
|
|
wantOutdatedNames: []string{"legacy"},
|
|
},
|
|
{
|
|
name: "legacy runner becomes stale once a revision is applied",
|
|
runners: []v1alpha1.EphemeralRunner{{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "legacy"},
|
|
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseOutdated},
|
|
}},
|
|
appliedRevision: 1,
|
|
wantStaleOutdatedName: []string{"legacy"},
|
|
},
|
|
{
|
|
name: "a runner being deleted is never classified as outdated",
|
|
runners: []v1alpha1.EphemeralRunner{func() v1alpha1.EphemeralRunner {
|
|
runner := outdatedRunnerAtRevision("terminating", 3)
|
|
now := metav1.Now()
|
|
runner.DeletionTimestamp = &now
|
|
runner.Finalizers = []string{ephemeralRunnerFinalizerName}
|
|
return runner
|
|
}()},
|
|
appliedRevision: 3,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
list := &v1alpha1.EphemeralRunnerList{Items: tt.runners}
|
|
state := newEphemeralRunnersByStates(list, tt.appliedRevision)
|
|
|
|
assert.Equal(t, tt.wantOutdatedNames, runnerNames(state.outdated))
|
|
assert.Equal(t, tt.wantStaleOutdatedName, runnerNames(state.staleOutdated))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEphemeralRunnersByState_TerminatedIncludesStaleOutdated ensures the cleanup
|
|
// paths still collect stale outdated runners; they are excluded from the phase
|
|
// decision, not from garbage collection.
|
|
func TestEphemeralRunnersByState_TerminatedIncludesStaleOutdated(t *testing.T) {
|
|
list := &v1alpha1.EphemeralRunnerList{Items: []v1alpha1.EphemeralRunner{
|
|
outdatedRunnerAtRevision("stale", 1),
|
|
outdatedRunnerAtRevision("current", 5),
|
|
{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "succeeded"},
|
|
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseSucceeded},
|
|
},
|
|
{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "failed"},
|
|
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseFailed},
|
|
},
|
|
}}
|
|
|
|
state := newEphemeralRunnersByStates(list, 5)
|
|
|
|
assert.ElementsMatch(t,
|
|
[]string{"succeeded", "failed", "current", "stale"},
|
|
runnerNames(state.terminated()),
|
|
)
|
|
}
|
|
|
|
// TestEphemeralRunnersByState_TerminatedDoesNotAliasBackingArrays guards against
|
|
// terminated() corrupting the slices it concatenates, which would silently
|
|
// reclassify runners.
|
|
func TestEphemeralRunnersByState_TerminatedDoesNotAliasBackingArrays(t *testing.T) {
|
|
list := &v1alpha1.EphemeralRunnerList{Items: []v1alpha1.EphemeralRunner{
|
|
{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "succeeded"},
|
|
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseSucceeded},
|
|
},
|
|
{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "failed"},
|
|
Status: v1alpha1.EphemeralRunnerStatus{Phase: v1alpha1.EphemeralRunnerPhaseFailed},
|
|
},
|
|
}}
|
|
|
|
state := newEphemeralRunnersByStates(list, 0)
|
|
_ = state.terminated()
|
|
|
|
assert.Equal(t, []string{"succeeded"}, runnerNames(state.finished))
|
|
assert.Equal(t, []string{"failed"}, runnerNames(state.failed))
|
|
}
|
|
|
|
// TestEphemeralRunnerSetOutdatedForAppliedRevision covers the guard that stops the
|
|
// AutoscalingRunnerSet from tearing the scale set down on an Outdated verdict that
|
|
// predates the runner spec it has just published.
|
|
func TestEphemeralRunnerSetOutdatedForAppliedRevision(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
phase v1alpha1.EphemeralRunnerSetPhase
|
|
specRevision int64
|
|
appliedRevision int64
|
|
want bool
|
|
}{
|
|
{
|
|
name: "nil-safe: running set is not outdated",
|
|
phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "outdated against the spec it is running",
|
|
phase: v1alpha1.EphemeralRunnerSetPhaseOutdated,
|
|
specRevision: 4,
|
|
appliedRevision: 4,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "outdated verdict predates a spec update that is still propagating",
|
|
phase: v1alpha1.EphemeralRunnerSetPhaseOutdated,
|
|
specRevision: 5,
|
|
appliedRevision: 4,
|
|
want: false,
|
|
},
|
|
{
|
|
name: "legacy set with no revisions recorded still tears down",
|
|
phase: v1alpha1.EphemeralRunnerSetPhaseOutdated,
|
|
specRevision: 0,
|
|
appliedRevision: 0,
|
|
want: true,
|
|
},
|
|
{
|
|
name: "running set with a pending revision is not outdated",
|
|
phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
|
specRevision: 5,
|
|
appliedRevision: 4,
|
|
want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{
|
|
Spec: v1alpha1.EphemeralRunnerSetSpec{ActionableRevision: tt.specRevision},
|
|
Status: v1alpha1.EphemeralRunnerSetStatus{Phase: tt.phase, AppliedActionableRevision: tt.appliedRevision},
|
|
}
|
|
assert.Equal(t, tt.want, ephemeralRunnerSetOutdatedForAppliedRevision(ephemeralRunnerSet))
|
|
})
|
|
}
|
|
|
|
assert.False(t, ephemeralRunnerSetOutdatedForAppliedRevision(nil))
|
|
}
|
|
|
|
func runnerNames(runners []*v1alpha1.EphemeralRunner) []string {
|
|
if len(runners) == 0 {
|
|
return nil
|
|
}
|
|
names := make([]string, 0, len(runners))
|
|
for _, runner := range runners {
|
|
names = append(names, runner.Name)
|
|
}
|
|
return names
|
|
}
|