Files
actions-runner-controller/controllers/actions.github.com/autoscalingrunnerset_metadata_test.go
T

283 lines
12 KiB
Go

package actionsgithubcom
import (
"context"
"fmt"
"time"
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
"github.com/actions/actions-runner-controller/build"
scalefake "github.com/actions/actions-runner-controller/controllers/actions.github.com/multiclient/fake"
"github.com/actions/actions-runner-controller/controllers/actions.github.com/secretresolver"
"github.com/actions/scaleset"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
corev1 "k8s.io/api/core/v1"
rbacv1 "k8s.io/api/rbac/v1"
kerrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/types"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
logf "sigs.k8s.io/controller-runtime/pkg/log"
)
type metadataPatchRecorder struct {
client.Client
patches []string
}
func (c *metadataPatchRecorder) Patch(ctx context.Context, obj client.Object, patch client.Patch, opts ...client.PatchOption) error {
c.patches = append(c.patches, fmt.Sprintf("%T/%s", obj, obj.GetName()))
return c.Client.Patch(ctx, obj, patch, opts...)
}
var _ = Describe("Resource metadata empty collection convergence", func() {
fields := []string{
"autoscalingListener",
"listenerServiceAccountMetadata",
"listenerRoleMetadata",
"listenerRoleBindingMetadata",
"listenerConfigSecretMetadata",
"ephemeralRunnerSetMetadata",
"ephemeralRunnerMetadata",
"ephemeralRunnerConfigSecretMetadata",
}
for _, field := range append(fields, "all") {
for _, keys := range [][]string{{"labels"}, {"annotations"}, {"labels", "annotations"}} {
It(fmt.Sprintf("%s with empty %v", field, keys), func() {
ctx, cancel := context.WithTimeout(context.Background(), autoscalingRunnerSetTestTimeout)
defer cancel()
ns, mgr := createNamespace(GinkgoT(), k8sClient)
secret := createDefaultSecret(GinkgoT(), k8sClient, ns.Name)
const name = "empty-metadata"
scaleSet := &scaleset.RunnerScaleSet{ID: 1, Name: name, RunnerGroupID: 1, RunnerGroupName: "Default"}
cache := newTestResourceCache()
builder := ResourceBuilder{
Scheme: mgr.GetScheme(), ResourceCache: cache,
SecretResolver: secretresolver.New(k8sClient, scalefake.NewMultiClient(scalefake.WithClient(
scalefake.NewClient(
scalefake.WithCreateRunnerScaleSet(scaleSet, nil),
scalefake.WithGetRunnerScaleSetByID(scaleSet, nil),
scalefake.WithGenerateJitRunnerConfig(&scaleset.RunnerScaleSetJitRunnerConfig{
Runner: &scaleset.RunnerReference{ID: 1, Name: "test-runner", RunnerScaleSetID: 1},
EncodedJITConfig: "fake-jit-config",
}, nil),
),
))),
}
cachedClient := &metadataPatchRecorder{Client: mgr.GetClient()}
directClient := &metadataPatchRecorder{Client: k8sClient}
arsController := &AutoscalingRunnerSetReconciler{
Client: cachedClient, Scheme: mgr.GetScheme(), Log: logf.Log,
ControllerNamespace: ns.Name, DefaultRunnerScaleSetListenerImage: "listener:latest",
ResourceBuilder: builder,
}
listenerController := &AutoscalingListenerReconciler{
Client: directClient, Scheme: mgr.GetScheme(), Log: logf.Log,
ListenerMetricsAddr: "0", ResourceBuilder: builder,
}
ersController := &EphemeralRunnerSetReconciler{
Client: cachedClient, APIReader: k8sClient, Scheme: mgr.GetScheme(), Log: logf.Log,
ResourceBuilder: builder,
}
runnerController := &EphemeralRunnerReconciler{
Client: directClient, APIReader: k8sClient, Scheme: mgr.GetScheme(), Log: logf.Log,
ResourceBuilder: builder,
}
startManagers(GinkgoT(), mgr)
spec := map[string]any{
"githubConfigUrl": "https://github.com/owner/repo", "githubConfigSecret": secret.Name,
"template": map[string]any{"spec": map[string]any{
"containers": []any{map[string]any{"name": "runner", "image": "runner:latest"}},
}},
}
selected := []string{field}
if field == "all" {
selected = fields
}
for _, resource := range selected {
metadata := map[string]any{}
for _, key := range keys {
metadata[key] = map[string]any{}
}
spec[resource] = metadata
}
if field == "all" {
Expect(unstructured.SetNestedMap(spec, spec["autoscalingListener"].(map[string]any), "template", "metadata")).To(Succeed())
Expect(unstructured.SetNestedMap(spec, spec["autoscalingListener"].(map[string]any), "listenerTemplate", "metadata")).To(Succeed())
Expect(unstructured.SetNestedSlice(spec, []any{map[string]any{"name": "listener"}}, "listenerTemplate", "spec", "containers")).To(Succeed())
spec["proxy"] = map[string]any{
"http": map[string]any{"url": "http://proxy.example.com:8080", "credentialSecretRef": "proxy-auth"},
}
Expect(k8sClient.Create(ctx, &corev1.Secret{
ObjectMeta: metav1.ObjectMeta{Name: "proxy-auth", Namespace: ns.Name},
Data: map[string][]byte{"username": []byte("user"), "password": []byte("password")},
})).To(Succeed())
}
raw := &unstructured.Unstructured{Object: map[string]any{
"apiVersion": v1alpha1.GroupVersion.String(), "kind": "AutoscalingRunnerSet",
"metadata": map[string]any{
"name": name, "namespace": ns.Name,
"labels": map[string]any{LabelKeyKubernetesVersion: build.Version},
},
"spec": spec,
}}
// Typed Create erases these maps before admission, hiding the regression.
Expect(k8sClient.Create(ctx, raw)).To(Succeed())
arsKey := client.ObjectKeyFromObject(raw)
ars := new(v1alpha1.AutoscalingRunnerSet)
Expect(k8sClient.Get(ctx, arsKey, ars)).To(Succeed())
listenerKey := client.ObjectKey{Namespace: ns.Name, Name: scaleSetListenerName(ars)}
waitForCache := func(key client.ObjectKey, obj client.Object) {
err := k8sClient.Get(ctx, key, obj)
if kerrors.IsNotFound(err) {
Eventually(func() bool {
return kerrors.IsNotFound(mgr.GetClient().Get(ctx, key, obj))
}, autoscalingRunnerSetTestTimeout, 10*time.Millisecond).Should(BeTrue())
return
}
Expect(err).NotTo(HaveOccurred())
version := obj.GetResourceVersion()
Eventually(func(g Gomega) {
g.Expect(mgr.GetClient().Get(ctx, key, obj)).To(Succeed())
g.Expect(obj.GetResourceVersion()).To(Equal(version))
}, autoscalingRunnerSetTestTimeout, 10*time.Millisecond).Should(Succeed())
}
reconcileARS := func() {
waitForCache(arsKey, new(v1alpha1.AutoscalingRunnerSet))
waitForCache(arsKey, new(v1alpha1.EphemeralRunnerSet))
waitForCache(listenerKey, new(v1alpha1.AutoscalingListener))
_, err := arsController.Reconcile(ctx, ctrl.Request{NamespacedName: arsKey})
Expect(err).NotTo(HaveOccurred())
}
reconcileListener := func() {
_, err := listenerController.Reconcile(ctx, ctrl.Request{NamespacedName: listenerKey})
Expect(err).NotTo(HaveOccurred())
}
for range 10 {
reconcileARS()
reconcileListener()
}
Expect(k8sClient.Get(ctx, arsKey, ars)).To(Succeed())
Expect(ars.Status.Phase).To(Equal(v1alpha1.AutoscalingRunnerSetPhaseRunning))
Expect(ars.Status.ObservedGeneration).To(Equal(ars.Generation))
ers := new(v1alpha1.EphemeralRunnerSet)
Expect(k8sClient.Get(ctx, arsKey, ers)).To(Succeed())
Expect(k8sClient.Patch(ctx, ers, client.RawPatch(types.MergePatchType, []byte(`{"spec":{"replicas":1}}`)))).To(Succeed())
reconcileERS := func() {
waitForCache(arsKey, new(v1alpha1.EphemeralRunnerSet))
_, err := ersController.Reconcile(ctx, ctrl.Request{NamespacedName: arsKey})
Expect(err).NotTo(HaveOccurred())
}
for range 2 {
reconcileERS()
}
runners := new(v1alpha1.EphemeralRunnerList)
Expect(k8sClient.List(ctx, runners, client.InNamespace(ns.Name))).To(Succeed())
Expect(runners.Items).To(HaveLen(1))
runnerKey := client.ObjectKeyFromObject(&runners.Items[0])
reconcileRunner := func() {
_, err := runnerController.Reconcile(ctx, ctrl.Request{NamespacedName: runnerKey})
Expect(err).NotTo(HaveOccurred())
waitForCache(runnerKey, new(v1alpha1.EphemeralRunner))
}
reconcileRunner()
objects := []client.Object{
ars, ers,
&v1alpha1.AutoscalingListener{ObjectMeta: metav1.ObjectMeta{Name: listenerKey.Name, Namespace: ns.Name}},
&corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: listenerKey.Name, Namespace: ns.Name}},
&rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: listenerKey.Name, Namespace: ns.Name}},
&rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: listenerKey.Name, Namespace: ns.Name}},
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: listenerKey.Name + "-config", Namespace: ns.Name}},
&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: listenerKey.Name, Namespace: ns.Name}},
&runners.Items[0],
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: runnerKey.Name, Namespace: ns.Name}},
&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: runnerKey.Name, Namespace: ns.Name}},
}
if field == "all" {
objects = append(objects,
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: listenerKey.Name + "-proxy", Namespace: ns.Name}},
&corev1.Secret{ObjectMeta: metav1.ObjectMeta{Name: proxyEphemeralRunnerSetSecretName(ers), Namespace: ns.Name}},
)
}
reconcileAll := func() {
reconcileARS()
reconcileListener()
reconcileERS()
reconcileRunner()
}
for range 3 {
reconcileAll()
}
for _, obj := range objects {
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(obj), obj)).To(Succeed())
Expect(obj.GetDeletionTimestamp().IsZero()).To(BeTrue())
// Creation normalizes cached desired pointers. Rebuild them as a
// restart or dependency update would, rather than testing only hits.
cache.Delete(obj)
}
cachedClient.patches, directClient.patches = nil, nil
for range 5 {
reconcileAll()
for _, obj := range objects {
fresh := obj.DeepCopyObject().(client.Object)
Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(obj), fresh)).To(Succeed())
Expect(fresh.GetUID()).To(Equal(obj.GetUID()), "%T must not be replaced", obj)
Expect(fresh.GetResourceVersion()).To(Equal(obj.GetResourceVersion()), "%T must not be rewritten", obj)
Expect(fresh.GetDeletionTimestamp().IsZero()).To(BeTrue())
}
}
Expect(cachedClient.patches).To(BeEmpty(), "even no-op patches must not starve later reconciliation")
Expect(directClient.patches).To(BeEmpty())
Expect(k8sClient.Get(ctx, arsKey, raw)).To(Succeed())
for _, resource := range selected {
for _, key := range keys {
value, found, err := unstructured.NestedStringMap(raw.Object, "spec", resource, key)
Expect(err).NotTo(HaveOccurred())
Expect(found).To(BeTrue(), "controller patches must retain the explicit empty input")
Expect(value).To(BeEmpty())
}
}
By("propagating a genuine spec change while keeping all empty metadata inputs")
listener := new(v1alpha1.AutoscalingListener)
Expect(k8sClient.Get(ctx, listenerKey, listener)).To(Succeed())
oldUID := listener.UID
Expect(k8sClient.Patch(ctx, raw, client.RawPatch(types.MergePatchType, []byte(`{"spec":{"maxRunners":2}}`)))).To(Succeed())
for range 5 {
reconcileARS()
Expect(k8sClient.Get(ctx, listenerKey, listener)).To(Succeed())
if !listener.DeletionTimestamp.IsZero() {
break
}
}
Expect(listener.DeletionTimestamp.IsZero()).To(BeFalse(), "empty runner metadata must not block listener updates")
for range 10 {
reconcileListener()
err := k8sClient.Get(ctx, listenerKey, listener)
if kerrors.IsNotFound(err) {
break
}
Expect(err).NotTo(HaveOccurred())
}
Expect(kerrors.IsNotFound(k8sClient.Get(ctx, listenerKey, listener))).To(BeTrue())
for range 10 {
reconcileARS()
reconcileListener()
}
Expect(k8sClient.Get(ctx, listenerKey, listener)).To(Succeed())
Expect(listener.UID).NotTo(Equal(oldUID))
Expect(listener.Spec.MaxRunners).To(Equal(2))
Expect(listener.DeletionTimestamp.IsZero()).To(BeTrue())
Expect(k8sClient.Get(ctx, arsKey, ars)).To(Succeed())
Expect(ars.Status.Phase).To(Equal(v1alpha1.AutoscalingRunnerSetPhaseRunning))
})
}
}
})