mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-09-30 13:08:45 +02:00
The neighbouring applied-revision helper refuses to move its marker backwards, so the obvious tidy-up is to make this one match. That would break it. Applied revisions derive from metadata.generation and only ever climb. Listener patch IDs do not. setDesiredWorkerState publishes 0 whenever the set is idle at MinRunners with nothing dirty, restarts its sequence from 0 when the listener restarts, and wraps explicitly at MaxInt32, so Spec.PatchID legitimately moves down. The marker only means "the gap below Spec.Replicas was created by cleaning up for exactly this patch ID", so it has to follow the patch ID wherever it goes. A marker that refused to descend would sit above every value the listener went on to publish, and since the guard suppresses only on an exact match, suppression would never fire again -- silently disabling the behaviour this layer adds. This also settles what the optimistic lock is and is not for. It cannot make an older patch ID unwritable, because the retry re-reads and re-applies the same argument, and recording the patch ID whose cleanup actually happened is a true statement regardless of ordering. What it prevents is a write decided against state that has since changed. The test pins the descending case, so a future reader who reaches for >= gets a failure rather than a silently inert guard. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
194 lines
7.0 KiB
Go
194 lines
7.0 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"
|
|
)
|
|
|
|
// TestPatchFinishedRunnerCleanupPatchIDStatusUsesOptimisticLock pins the
|
|
// resourceVersion precondition on the cleanup marker patch.
|
|
//
|
|
// The marker decides whether a shortfall below Spec.Replicas is suppressed, so a
|
|
// write that lands on the wrong patch ID re-enables the spurious scale up this
|
|
// layer exists to prevent. The helper sets the marker to whatever patch ID the
|
|
// reconcile is carrying rather than only ever advancing it, so without the lock
|
|
// the API server cannot reject a stale write, retry.RetryOnConflict never fires,
|
|
// and a reconcile serving an older patch ID can overwrite a marker recorded for
|
|
// a newer one.
|
|
//
|
|
// Asserted on the bytes the production code emits rather than on an
|
|
// independently built patch, so it cannot pass while the reconciler constructs
|
|
// its patch some other way. Reverting the option to a plain client.MergeFrom
|
|
// must fail this test.
|
|
func TestPatchFinishedRunnerCleanupPatchIDStatusUsesOptimisticLock(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{
|
|
PatchID: 4,
|
|
},
|
|
}
|
|
|
|
var capturedPatch []byte
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(ephemeralRunnerSet).
|
|
WithStatusSubresource(&v1alpha1.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,
|
|
APIReader: c,
|
|
Log: logr.Discard(),
|
|
Scheme: scheme,
|
|
}
|
|
|
|
key := types.NamespacedName{Namespace: ephemeralRunnerSet.Namespace, Name: ephemeralRunnerSet.Name}
|
|
require.NoError(t, reconciler.patchFinishedRunnerCleanupPatchIDStatus(context.Background(), key, 4))
|
|
|
|
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 {
|
|
FinishedRunnerCleanupPatchID int `json:"finishedRunnerCleanupPatchID"`
|
|
} `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 recording the wrong patch ID, got %s",
|
|
string(capturedPatch),
|
|
)
|
|
assert.Equal(t, 4, emitted.Status.FinishedRunnerCleanupPatchID)
|
|
|
|
var updated v1alpha1.EphemeralRunnerSet
|
|
require.NoError(t, c.Get(context.Background(), key, &updated))
|
|
assert.Equal(t, 4, updated.Status.FinishedRunnerCleanupPatchID)
|
|
}
|
|
|
|
// TestPatchFinishedRunnerCleanupPatchIDStatusRecordsALowerPatchID pins the
|
|
// equality check against being "tidied" into the >= monotonicity check its
|
|
// neighbour uses.
|
|
//
|
|
// Applied revisions come from metadata.generation and only climb, but listener
|
|
// patch IDs do not: the scaler publishes 0 whenever the set is idle at
|
|
// MinRunners with nothing dirty, restarts its sequence from 0 on a listener
|
|
// restart, and wraps explicitly at math.MaxInt32. A marker that refused to move
|
|
// down would sit above every value the listener subsequently publishes, and
|
|
// because the scale-up guard suppresses only on an exact match, suppression
|
|
// would never fire again.
|
|
func TestPatchFinishedRunnerCleanupPatchIDStatusRecordsALowerPatchID(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{
|
|
FinishedRunnerCleanupPatchID: 7,
|
|
},
|
|
}
|
|
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(ephemeralRunnerSet).
|
|
WithStatusSubresource(&v1alpha1.EphemeralRunnerSet{}).
|
|
Build()
|
|
|
|
reconciler := &EphemeralRunnerSetReconciler{
|
|
Client: c,
|
|
APIReader: c,
|
|
Log: logr.Discard(),
|
|
Scheme: scheme,
|
|
}
|
|
|
|
key := types.NamespacedName{Namespace: ephemeralRunnerSet.Namespace, Name: ephemeralRunnerSet.Name}
|
|
require.NoError(t, reconciler.patchFinishedRunnerCleanupPatchIDStatus(context.Background(), key, 1))
|
|
|
|
var updated v1alpha1.EphemeralRunnerSet
|
|
require.NoError(t, c.Get(context.Background(), key, &updated))
|
|
assert.Equal(t, 1, updated.Status.FinishedRunnerCleanupPatchID,
|
|
"a restarted or collapsed patch sequence must be recorded, or suppression can never match Spec.PatchID again")
|
|
}
|
|
|
|
// TestPatchFinishedRunnerCleanupPatchIDStatusIsIdempotent covers the early
|
|
// return: a marker already recording this patch ID must not be rewritten, so
|
|
// repeated reconciles for one patch do not churn the status.
|
|
func TestPatchFinishedRunnerCleanupPatchIDStatusIsIdempotent(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{
|
|
FinishedRunnerCleanupPatchID: 4,
|
|
},
|
|
}
|
|
|
|
patched := false
|
|
c := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(ephemeralRunnerSet).
|
|
WithStatusSubresource(&v1alpha1.EphemeralRunnerSet{}).
|
|
WithInterceptorFuncs(interceptor.Funcs{
|
|
SubResourcePatch: func(ctx context.Context, clt client.Client, subResourceName string, obj client.Object, patch client.Patch, opts ...client.SubResourcePatchOption) error {
|
|
patched = true
|
|
return clt.Status().Patch(ctx, obj, patch, opts...)
|
|
},
|
|
}).
|
|
Build()
|
|
|
|
reconciler := &EphemeralRunnerSetReconciler{
|
|
Client: c,
|
|
APIReader: c,
|
|
Log: logr.Discard(),
|
|
Scheme: scheme,
|
|
}
|
|
|
|
key := types.NamespacedName{Namespace: ephemeralRunnerSet.Namespace, Name: ephemeralRunnerSet.Name}
|
|
require.NoError(t, reconciler.patchFinishedRunnerCleanupPatchIDStatus(context.Background(), key, 4))
|
|
|
|
assert.False(t, patched, "recording a patch ID the marker already holds must not emit a write")
|
|
}
|