mirror of
https://github.com/actions-runner-controller/actions-runner-controller.git
synced 2026-09-30 18:33:30 +02:00
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
145 lines
5.3 KiB
Go
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)
|
|
}
|