Files
actions-runner-controller/controllers/actions.github.com/ephemeralrunnerset_revision_patch_test.go
T
Nikola JokicandCopilot App 8befb9d8d7 Derive the phase from an authoritative read of the child runners
patchAppliedActionableRevisionStatus reads the EphemeralRunnerSet through
the uncached reader precisely because a cached read cannot be trusted
there, then derived Status.Phase from a cached list of the child runners.
The optimistic lock on the patch covers the EphemeralRunnerSet object
alone, so it could not vouch for that list, while a successful write made
the result look consistent. The list also sits inside RetryOnConflict,
where a cached read can return the same stale data on every attempt.

The ownership filter moves into the process, because resourceOwnerKey is
a client-side index on the manager's cache and the API server rejects
.metadata.controller as an unsupported field label. The predicate lives
next to the indexer so the two do not drift apart.

Add the index to the revision patch test fakes, which need it now that
the function lists children, and a test that separates the two reads:
it fails when the list goes through the cache and passes when it does
not.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-09-11 11:21:07 +02:00

145 lines
5.3 KiB
Go

package actionsgithubcom
import (
"context"
"encoding/json"
"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"
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
)
// TestPatchAppliedActionableRevisionStatusUsesOptimisticLock pins the property
// that makes the applied revision marker monotonic: the status patch must carry
// a resourceVersion precondition.
//
// Without it the API server cannot reject the write as conflicting, so the
// surrounding retry.RetryOnConflict never fires and the freshness check inside
// it is unsound. Both the target revision and the re-fetched object come from
// the cache-backed client, so a stale reconcile can compare a stale target
// against an equally stale read, pass the check, and patch the applied revision
// backwards. That re-satisfies the spec > applied comparison in Reconcile and
// sends the controller through the idle and pending runner cleanup again.
//
// The property is asserted on the bytes the production code actually emits
// rather than on an independently constructed patch, so the test cannot pass
// while the reconciler builds its patch some other way. Reverting the patch
// option to a plain client.MergeFrom must fail this test.
func TestPatchAppliedActionableRevisionStatusUsesOptimisticLock(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, clientgoscheme.AddToScheme(scheme))
require.NoError(t, v1alpha1.AddToScheme(scheme))
ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ers",
Namespace: "default",
},
Spec: v1alpha1.EphemeralRunnerSetSpec{
ActionableRevision: 7,
},
Status: v1alpha1.EphemeralRunnerSetStatus{
AppliedActionableRevision: 2,
},
}
var capturedPatch []byte
c := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ephemeralRunnerSet).
WithStatusSubresource(&v1alpha1.EphemeralRunnerSet{}).
WithIndex(&v1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")).
WithInterceptorFuncs(interceptor.Funcs{
SubResourcePatch: func(ctx context.Context, clt client.Client, subResourceName string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error {
data, err := patch.Data(obj)
if err != nil {
return err
}
capturedPatch = data
return clt.Status().Patch(ctx, obj, patch, opts...)
},
}).
Build()
reconciler := &EphemeralRunnerSetReconciler{
Client: c,
Log: logr.Discard(),
Scheme: scheme,
}
key := types.NamespacedName{Namespace: ephemeralRunnerSet.Namespace, Name: ephemeralRunnerSet.Name}
require.NoError(t, reconciler.patchAppliedActionableRevisionStatus(context.Background(), key, 7))
require.NotEmpty(t, capturedPatch, "expected the reconciler to emit a status patch")
var emitted struct {
Metadata struct {
ResourceVersion string `json:"resourceVersion"`
} `json:"metadata"`
Status struct {
AppliedActionableRevision int64 `json:"appliedActionableRevision"`
} `json:"status"`
}
require.NoError(t, json.Unmarshal(capturedPatch, &emitted))
assert.NotEmpty(
t,
emitted.Metadata.ResourceVersion,
"status patch must carry a resourceVersion precondition so a stale write is rejected instead of moving the applied revision backwards, got %s",
string(capturedPatch),
)
assert.Equal(t, int64(7), emitted.Status.AppliedActionableRevision)
var updated v1alpha1.EphemeralRunnerSet
require.NoError(t, c.Get(context.Background(), key, &updated))
assert.Equal(t, int64(7), updated.Status.AppliedActionableRevision)
}
// TestPatchAppliedActionableRevisionStatusDoesNotMoveBackwards covers the guard
// inside the retry: a reconcile carrying an older target revision must leave a
// marker that has already advanced further alone.
func TestPatchAppliedActionableRevisionStatusDoesNotMoveBackwards(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, clientgoscheme.AddToScheme(scheme))
require.NoError(t, v1alpha1.AddToScheme(scheme))
ephemeralRunnerSet := &v1alpha1.EphemeralRunnerSet{
ObjectMeta: metav1.ObjectMeta{
Name: "test-ers",
Namespace: "default",
},
Status: v1alpha1.EphemeralRunnerSetStatus{
AppliedActionableRevision: 5,
},
}
c := fake.NewClientBuilder().
WithScheme(scheme).
WithObjects(ephemeralRunnerSet).
WithStatusSubresource(&v1alpha1.EphemeralRunnerSet{}).
WithIndex(&v1alpha1.EphemeralRunner{}, resourceOwnerKey, newGroupVersionOwnerKindIndexer("EphemeralRunnerSet")).
Build()
reconciler := &EphemeralRunnerSetReconciler{
Client: c,
Log: logr.Discard(),
Scheme: scheme,
}
key := types.NamespacedName{Namespace: ephemeralRunnerSet.Namespace, Name: ephemeralRunnerSet.Name}
require.NoError(t, reconciler.patchAppliedActionableRevisionStatus(context.Background(), key, 3))
var updated v1alpha1.EphemeralRunnerSet
require.NoError(t, c.Get(context.Background(), key, &updated))
assert.Equal(t, int64(5), updated.Status.AppliedActionableRevision)
}