mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-09-30 23:28:58 +02:00
An EphemeralRunner that exits as Outdated marks its EphemeralRunnerSet
Outdated, and the AutoscalingRunnerSet then stops scaling it. Without a way
to tell which runner spec an Outdated report refers to, a report from a
runner built before the spec was updated keeps the set switched off after
the update that was supposed to fix it.
Stamp each runner with the actionable revision it was built from, and judge
Outdated reports against the revision the set has applied:
- A runner whose revision is older than the applied one is reporting on a
spec that has already been replaced. It is deleted and rebuilt from the
current spec, and it does not hold the set Outdated.
- A runner whose revision is current is reporting on the live spec, so the
set stays Outdated. It is deliberately not delete-and-replaced: a fresh
runner at the same revision would report Outdated again, forever.
Runners without the annotation parse to revision 0, which matches the zero
value of Status.AppliedActionableRevision, so existing runners keep their
current behaviour across an upgrade.
The phase is derived inside patchAppliedActionableRevisionStatus, from a
list read through the same authoritative reader as the set itself, because
the optimistic lock on that patch covers the EphemeralRunnerSet object only
and cannot vouch for a separately-read list. The runners are classified
against the live applied revision rather than the revision this call was
asked to apply: the caller reads the spec from the cache while this function
re-reads the status from the API server, so a lagging reconcile can arrive
with a target behind the live marker, and judging against it would flip a
set that has already moved on back to Outdated.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
118 lines
4.4 KiB
Go
118 lines
4.4 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"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
)
|
|
|
|
// TestPatchAppliedActionableRevisionStatusDerivesPhaseFromAuthoritativeRead
|
|
// pins the read that the derived phase is computed from.
|
|
//
|
|
// patchAppliedActionableRevisionStatus reads the EphemeralRunnerSet through
|
|
// APIReader precisely because a cached read cannot be trusted here, then
|
|
// derives Status.Phase from a list of the child runners. If that list goes
|
|
// through the cache-backed client instead, the phase is derived from data the
|
|
// surrounding optimistic lock does not cover: the lock proves only that the
|
|
// EphemeralRunnerSet was live at write time, never that the list was.
|
|
//
|
|
// A wrong phase does not merely flap. Reconcile's Outdated branch cleans up and
|
|
// returns before reaching updateStatus, and this function only runs while
|
|
// spec > applied, which it then makes false by advancing the marker. So a phase
|
|
// wrongly set to Outdated is never recomputed, and the set stays switched off.
|
|
//
|
|
// The two clients below disagree on purpose. The authoritative one holds no
|
|
// runners, so the correct phase is Running. The cached one holds an Outdated
|
|
// runner at the revision being applied, which is the phase a cached list would
|
|
// produce. Listing through the cache-backed client must fail this test.
|
|
func TestPatchAppliedActionableRevisionStatusDerivesPhaseFromAuthoritativeRead(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
require.NoError(t, clientgoscheme.AddToScheme(scheme))
|
|
require.NoError(t, v1alpha1.AddToScheme(scheme))
|
|
|
|
newEphemeralRunnerSet := func() *v1alpha1.EphemeralRunnerSet {
|
|
return &v1alpha1.EphemeralRunnerSet{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-ers",
|
|
Namespace: "default",
|
|
},
|
|
Spec: v1alpha1.EphemeralRunnerSetSpec{
|
|
ActionableRevision: 7,
|
|
},
|
|
Status: v1alpha1.EphemeralRunnerSetStatus{
|
|
AppliedActionableRevision: 2,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Phase Outdated at the revision being applied, so the classifier counts it
|
|
// as outdated rather than staleOutdated.
|
|
controllerRef := true
|
|
staleView := &v1alpha1.EphemeralRunner{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "runner-from-cache",
|
|
Namespace: "default",
|
|
Annotations: map[string]string{
|
|
AnnotationKeyActionableRevision: "7",
|
|
},
|
|
OwnerReferences: []metav1.OwnerReference{
|
|
{
|
|
APIVersion: v1alpha1.GroupVersion.String(),
|
|
Kind: "EphemeralRunnerSet",
|
|
Name: "test-ers",
|
|
UID: "test-uid",
|
|
Controller: &controllerRef,
|
|
},
|
|
},
|
|
},
|
|
Status: v1alpha1.EphemeralRunnerStatus{
|
|
Phase: v1alpha1.EphemeralRunnerPhaseOutdated,
|
|
},
|
|
}
|
|
|
|
builder := func(objs ...client.Object) client.Client {
|
|
return fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(objs...).
|
|
WithStatusSubresource(&v1alpha1.EphemeralRunnerSet{}).
|
|
WithIndex(&v1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")).
|
|
Build()
|
|
}
|
|
|
|
// The cache-backed client still sees a runner the API server no longer has.
|
|
cached := builder(newEphemeralRunnerSet(), staleView)
|
|
// The authoritative view: the runner is gone, so nothing is outdated.
|
|
authoritative := builder(newEphemeralRunnerSet())
|
|
|
|
reconciler := &EphemeralRunnerSetReconciler{
|
|
Client: cached,
|
|
APIReader: authoritative,
|
|
Log: logr.Discard(),
|
|
Scheme: scheme,
|
|
}
|
|
|
|
key := types.NamespacedName{Namespace: "default", Name: "test-ers"}
|
|
require.NoError(t, reconciler.patchAppliedActionableRevisionStatus(context.Background(), key, 7))
|
|
|
|
var patched v1alpha1.EphemeralRunnerSet
|
|
require.NoError(t, cached.Get(context.Background(), key, &patched))
|
|
|
|
assert.Equal(
|
|
t,
|
|
v1alpha1.EphemeralRunnerSetPhaseRunning,
|
|
patched.Status.Phase,
|
|
"phase must be derived from the authoritative read, not the cache: the cached client holds an outdated runner the API server no longer has, and a phase of Outdated is never recomputed because Reconcile's Outdated branch returns before updateStatus",
|
|
)
|
|
assert.Equal(t, int64(7), patched.Status.AppliedActionableRevision)
|
|
}
|