mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-09-30 14:47:11 +02:00
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
184 lines
6.9 KiB
Go
184 lines
6.9 KiB
Go
package actionsgithubcom
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
|
|
"github.com/go-logr/logr"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
)
|
|
|
|
// TestPatchAppliedActionableRevisionStatusClassifiesAgainstTheAdvancedRevision
|
|
// pins the other half of the classifier contract: which revision is used when
|
|
// the monotonicity guard actually fires.
|
|
//
|
|
// TestPatchAppliedActionableRevisionStatusIgnoresATargetBehindTheLiveRevision
|
|
// covers the case where the target has fallen behind the live marker, so the
|
|
// guard does not fire and the applied revision is unchanged. That case cannot
|
|
// observe this one. The guard writes the advance to a copy of the status, so
|
|
// only when it fires do the copy and the stored status hold different
|
|
// revisions, and only then can the classifier be pointed at the wrong one.
|
|
//
|
|
// Reading the pre-advance revision rates a runner left over from the superseded
|
|
// revision as current instead of staleOutdated, which sets the Outdated phase.
|
|
// That phase is then persisted next to the freshly advanced revision, which is
|
|
// the state this function exists to avoid: Reconcile's Outdated branch returns
|
|
// before updateStatus, and this function only runs while spec > applied, so
|
|
// nothing recomputes the phase and the set stays switched off until the next
|
|
// spec change.
|
|
func TestPatchAppliedActionableRevisionStatusClassifiesAgainstTheAdvancedRevision(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
require.NoError(t, clientgoscheme.AddToScheme(scheme))
|
|
require.NoError(t, v1alpha1.AddToScheme(scheme))
|
|
|
|
// The spec has moved to revision 2 while the status still records 1, so the
|
|
// guard below advances the marker and the two revisions differ.
|
|
ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-ers",
|
|
Namespace: "default",
|
|
},
|
|
Spec: v1alpha1.EphemeralRunnerSetSpec{
|
|
ActionableRevision: 2,
|
|
},
|
|
Status: v1alpha1.EphemeralRunnerSetStatus{
|
|
AppliedActionableRevision: 1,
|
|
Phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
|
},
|
|
}
|
|
|
|
// A runner built from revision 1 that the service rejected. Applying
|
|
// revision 2 deletes the idle and pending runners, but this one can survive
|
|
// the cleanup: the cleanup list is read through the cache and can be stale,
|
|
// which is the case the reviewer of this code identified.
|
|
//
|
|
// Its revision equals the pre-advance marker, so classifying against that
|
|
// value puts it in outdated rather than staleOutdated. Against the advanced
|
|
// revision it is correctly stale.
|
|
controllerRef := true
|
|
supersededRunner := &v1alpha1.EphemeralRunner{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "runner-from-revision-1",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
AnnotationKeyActionableRevision: "1",
|
|
},
|
|
OwnerReferences: []metav1.OwnerReference{
|
|
{
|
|
APIVersion: v1alpha1.GroupVersion.String(),
|
|
Kind: "EphemeralRunnerSet",
|
|
Name: "test-ers",
|
|
UID: "test-uid",
|
|
Controller: &controllerRef,
|
|
},
|
|
},
|
|
},
|
|
Status: v1alpha1.EphemeralRunnerStatus{
|
|
Phase: v1alpha1.EphemeralRunnerPhaseOutdated,
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(ephemeralRunnerSet, supersededRunner).
|
|
WithStatusSubresource(&v1alpha1.EphemeralRunnerSet{}).
|
|
WithIndex(&v1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")).
|
|
Build()
|
|
|
|
reconciler := &EphemeralRunnerSetReconciler{
|
|
Client: fakeClient,
|
|
APIReader: fakeClient,
|
|
Log: logr.Discard(),
|
|
Scheme: scheme,
|
|
}
|
|
|
|
key := types.NamespacedName{Namespace: "default", Name: "test-ers"}
|
|
|
|
require.NoError(t, reconciler.patchAppliedActionableRevisionStatus(context.Background(), key, 2))
|
|
|
|
var patched v1alpha1.EphemeralRunnerSet
|
|
require.NoError(t, fakeClient.Get(context.Background(), key, &patched))
|
|
|
|
assert.Equal(
|
|
t,
|
|
int64(2),
|
|
patched.Status.AppliedActionableRevision,
|
|
"the guard must advance the marker to the target revision",
|
|
)
|
|
|
|
assert.Equal(
|
|
t,
|
|
v1alpha1.EphemeralRunnerSetPhaseRunning,
|
|
patched.Status.Phase,
|
|
"the leftover runner reported Outdated against revision 1, which this call has just superseded, so it must be classified against the advanced revision and ignored: counting it against the pre-advance revision saves the Outdated phase alongside revision 2, and nothing recomputes the phase from there",
|
|
)
|
|
}
|
|
|
|
// TestPatchAppliedActionableRevisionStatusDoesNotPatchWhenNothingChanges pins
|
|
// the other half of that contract: the call must issue no patch at all when the
|
|
// status already says what it would set.
|
|
//
|
|
// This is the reason the object is not deep copied up front, and it is decided
|
|
// by whether any mutation was actually made rather than by comparing a
|
|
// separately built status value. Losing it would send an empty patch on every
|
|
// reconcile that reaches this function, and each write bumps the resourceVersion
|
|
// and wakes every watcher, so the regression is invisible in behaviour and
|
|
// visible only in load.
|
|
func TestPatchAppliedActionableRevisionStatusDoesNotPatchWhenNothingChanges(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
require.NoError(t, clientgoscheme.AddToScheme(scheme))
|
|
require.NoError(t, v1alpha1.AddToScheme(scheme))
|
|
|
|
// Already applied revision 3, already Running, marker already clear, and no
|
|
// child runners to move the phase. Every write below is therefore a no-op.
|
|
ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-ers",
|
|
Namespace: "default",
|
|
},
|
|
Status: v1alpha1.EphemeralRunnerSetStatus{
|
|
AppliedActionableRevision: 3,
|
|
Phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(ephemeralRunnerSet).
|
|
WithStatusSubresource(&v1alpha1.EphemeralRunnerSet{}).
|
|
WithIndex(&v1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")).
|
|
Build()
|
|
|
|
reconciler := &EphemeralRunnerSetReconciler{
|
|
Client: fakeClient,
|
|
APIReader: fakeClient,
|
|
Log: logr.Discard(),
|
|
Scheme: scheme,
|
|
}
|
|
|
|
key := types.NamespacedName{Namespace: "default", Name: "test-ers"}
|
|
|
|
var before v1alpha1.EphemeralRunnerSet
|
|
require.NoError(t, fakeClient.Get(context.Background(), key, &before))
|
|
|
|
require.NoError(t, reconciler.patchAppliedActionableRevisionStatus(context.Background(), key, 3))
|
|
|
|
var after v1alpha1.EphemeralRunnerSet
|
|
require.NoError(t, fakeClient.Get(context.Background(), key, &after))
|
|
|
|
assert.Equal(
|
|
t,
|
|
before.ResourceVersion,
|
|
after.ResourceVersion,
|
|
"the status already matched, so no patch should have been sent: the resourceVersion moving means an empty patch was written anyway",
|
|
)
|
|
assert.Equal(t, before.Status, after.Status)
|
|
}
|