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

506 lines
20 KiB
Go

package actionsgithubcom
import (
"reflect"
"sort"
"testing"
"time"
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
"github.com/stretchr/testify/assert"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
"sigs.k8s.io/controller-runtime/pkg/event"
)
func TestAutoscalingRunnerSetOwnedEphemeralRunnerSetPredicate(t *testing.T) {
base := func() *v1alpha1.EphemeralRunnerSet {
return &v1alpha1.EphemeralRunnerSet{
ObjectMeta: metav1.ObjectMeta{
Name: "runner-set",
Namespace: "default",
Generation: 1,
Labels: map[string]string{"app": "runner"},
Annotations: map[string]string{AnnotationKeyPatchID: "1"},
Finalizers: []string{"finalizer"},
},
Spec: v1alpha1.EphemeralRunnerSetSpec{
Replicas: 2,
PatchID: 3,
ActionableRevision: 4,
},
Status: v1alpha1.EphemeralRunnerSetStatus{
Phase: v1alpha1.EphemeralRunnerSetPhaseRunning,
},
}
}
// Every field the AutoscalingRunnerSet reconciler reads off an owned
// EphemeralRunnerSet has to wake it up.
for name, mutate := range map[string]func(*v1alpha1.EphemeralRunnerSet){
"replicas": func(s *v1alpha1.EphemeralRunnerSet) { s.Spec.Replicas = 5 },
"patch id": func(s *v1alpha1.EphemeralRunnerSet) { s.Spec.PatchID = 9 },
"actionable revision": func(s *v1alpha1.EphemeralRunnerSet) { s.Spec.ActionableRevision = 7 },
"ephemeral runner spec": func(s *v1alpha1.EphemeralRunnerSet) {
s.Spec.EphemeralRunnerSpec.GitHubConfigURL = "https://github.com/org"
},
"ephemeral runner meta": func(s *v1alpha1.EphemeralRunnerSet) {
s.Spec.EphemeralRunnerMetadata = &v1alpha1.ResourceMeta{Labels: map[string]string{"a": "b"}}
},
"labels": func(s *v1alpha1.EphemeralRunnerSet) { s.Labels["app"] = "changed" },
"annotations": func(s *v1alpha1.EphemeralRunnerSet) { s.Annotations[AnnotationKeyPatchID] = "2" },
"finalizers": func(s *v1alpha1.EphemeralRunnerSet) { s.Finalizers = nil },
"deletion timestamp": func(s *v1alpha1.EphemeralRunnerSet) { s.DeletionTimestamp = &metav1.Time{Time: time.Now()} },
"generation": func(s *v1alpha1.EphemeralRunnerSet) { s.Generation = 2 },
"owner references": func(s *v1alpha1.EphemeralRunnerSet) { s.OwnerReferences = []metav1.OwnerReference{{Name: "owner"}} },
"phase": func(s *v1alpha1.EphemeralRunnerSet) {
s.Status.Phase = v1alpha1.EphemeralRunnerSetPhaseOutdated
},
"applied actionable revision": func(s *v1alpha1.EphemeralRunnerSet) {
s.Status.AppliedActionableRevision = 9
},
} {
t.Run("reconciles on "+name, func(t *testing.T) {
old, updated := base(), base()
mutate(updated)
assert.True(t, autoscalingRunnerSetOwnedEphemeralRunnerSetPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
}
t.Run("ignores the finished runner cleanup patch id", func(t *testing.T) {
old, updated := base(), base()
updated.Status.FinishedRunnerCleanupPatchID = 3
updated.ResourceVersion = "2"
assert.False(t, autoscalingRunnerSetOwnedEphemeralRunnerSetPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
t.Run("reconciles on unexpected types", func(t *testing.T) {
assert.True(t, autoscalingRunnerSetOwnedEphemeralRunnerSetPredicate().Update(event.UpdateEvent{
ObjectOld: &corev1.Pod{},
ObjectNew: &corev1.Pod{},
}))
})
t.Run("does not filter create, delete or generic events", func(t *testing.T) {
p := autoscalingRunnerSetOwnedEphemeralRunnerSetPredicate()
assert.True(t, p.Create(event.CreateEvent{Object: base()}))
assert.True(t, p.Delete(event.DeleteEvent{Object: base()}))
assert.True(t, p.Generic(event.GenericEvent{Object: base()}))
})
}
func TestEphemeralRunnerSetOwnedEphemeralRunnerPredicate(t *testing.T) {
base := func() *v1alpha1.EphemeralRunner {
return &v1alpha1.EphemeralRunner{
ObjectMeta: metav1.ObjectMeta{
Name: "runner",
Namespace: "default",
Generation: 1,
Annotations: map[string]string{
AnnotationKeyPatchID: "1",
AnnotationKeyActionableRevision: "1",
},
Finalizers: []string{"finalizer"},
},
Status: v1alpha1.EphemeralRunnerStatus{
Phase: v1alpha1.EphemeralRunnerPhaseRunning,
RunnerID: 42,
},
}
}
// The EphemeralRunnerSet reconciler groups runners by phase, reads the patch
// id and actionable revision annotations, and needs the runner id to remove
// the runner from the service.
for name, mutate := range map[string]func(*v1alpha1.EphemeralRunner){
"phase": func(r *v1alpha1.EphemeralRunner) { r.Status.Phase = v1alpha1.EphemeralRunnerPhaseSucceeded },
"runner id": func(r *v1alpha1.EphemeralRunner) { r.Status.RunnerID = 43 },
"job id": func(r *v1alpha1.EphemeralRunner) { r.Status.JobID = "job" },
"patch id annotation": func(r *v1alpha1.EphemeralRunner) { r.Annotations[AnnotationKeyPatchID] = "2" },
"actionable revision": func(r *v1alpha1.EphemeralRunner) { r.Annotations[AnnotationKeyActionableRevision] = "2" },
"labels": func(r *v1alpha1.EphemeralRunner) { r.Labels = map[string]string{"a": "b"} },
"finalizers": func(r *v1alpha1.EphemeralRunner) { r.Finalizers = nil },
"deletion timestamp": func(r *v1alpha1.EphemeralRunner) { r.DeletionTimestamp = &metav1.Time{Time: time.Now()} },
"generation": func(r *v1alpha1.EphemeralRunner) { r.Generation = 2 },
"spec": func(r *v1alpha1.EphemeralRunner) { r.Spec.GitHubConfigURL = "https://github.com/org" },
} {
t.Run("reconciles on "+name, func(t *testing.T) {
old, updated := base(), base()
mutate(updated)
assert.True(t, ephemeralRunnerSetOwnedEphemeralRunnerPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
}
t.Run("ignores runner status the runner set never reads", func(t *testing.T) {
old, updated := base(), base()
updated.Status.Ready = true
updated.Status.Reason = "reason"
updated.Status.Message = "message"
updated.Status.RunnerName = "runner-name"
updated.Status.Failures = map[string]metav1.Time{"pod": metav1.Now()}
updated.Status.JobRequestID = 7
updated.Status.JobDisplayName = "display"
updated.Status.JobRepositoryName = "org/repo"
updated.Status.JobWorkflowRef = "ref"
updated.Status.WorkflowRunID = 12
updated.ResourceVersion = "2"
assert.False(t, ephemeralRunnerSetOwnedEphemeralRunnerPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
t.Run("reconciles on unexpected types", func(t *testing.T) {
assert.True(t, ephemeralRunnerSetOwnedEphemeralRunnerPredicate().Update(event.UpdateEvent{
ObjectOld: &corev1.Pod{},
ObjectNew: &corev1.Pod{},
}))
})
}
func TestEphemeralRunnerPredicate(t *testing.T) {
base := func() *v1alpha1.EphemeralRunner {
return &v1alpha1.EphemeralRunner{
ObjectMeta: metav1.ObjectMeta{
Name: "runner",
Namespace: "default",
Generation: 1,
Finalizers: []string{"finalizer"},
},
Spec: v1alpha1.EphemeralRunnerSpec{GitHubConfigURL: "https://github.com/org/repo"},
Status: v1alpha1.EphemeralRunnerStatus{
Phase: v1alpha1.EphemeralRunnerPhasePending,
Ready: true,
RunnerID: 42,
},
}
}
t.Run("reconciles on listener job assignment", func(t *testing.T) {
old, updated := base(), base()
updated.Status.JobID = "job"
assert.True(t, ephemeralRunnerPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
t.Run("reconciles on metadata and spec changes", func(t *testing.T) {
for name, mutate := range map[string]func(*v1alpha1.EphemeralRunner){
"finalizer": func(r *v1alpha1.EphemeralRunner) { r.Finalizers = nil },
"spec": func(r *v1alpha1.EphemeralRunner) { r.Spec.GitHubConfigURL = "https://github.com/other/repo" },
} {
t.Run(name, func(t *testing.T) {
old, updated := base(), base()
mutate(updated)
assert.True(t, ephemeralRunnerPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
}
})
t.Run("ignores controller-owned status writes", func(t *testing.T) {
old, updated := base(), base()
updated.Status.Phase = v1alpha1.EphemeralRunnerPhaseRunning
updated.Status.Ready = false
updated.Status.Reason = "reason"
updated.Status.Message = "message"
updated.Status.RunnerID = 43
updated.Status.RunnerName = "runner-name"
updated.Status.Failures = map[string]metav1.Time{"pod": metav1.Now()}
assert.False(t, ephemeralRunnerPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
t.Run("reconciles on unexpected types", func(t *testing.T) {
assert.True(t, ephemeralRunnerPredicate().Update(event.UpdateEvent{
ObjectOld: &corev1.Pod{},
ObjectNew: &corev1.Pod{},
}))
})
}
func TestEphemeralRunnerOwnedPodPredicate(t *testing.T) {
base := func() *corev1.Pod {
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "runner",
Namespace: "default",
UID: types.UID("uid"),
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
Conditions: []corev1.PodCondition{
{
Type: corev1.PodReady,
Status: corev1.ConditionTrue,
LastTransitionTime: metav1.NewTime(time.Now()),
},
},
ContainerStatuses: []corev1.ContainerStatus{
{
Name: v1alpha1.EphemeralRunnerContainerName,
Ready: true,
State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}},
},
},
InitContainerStatuses: []corev1.ContainerStatus{
{
Name: "init",
State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 0}},
},
},
},
}
}
// Every pod field the EphemeralRunner reconciler branches on has to wake it up.
for name, mutate := range map[string]func(*corev1.Pod){
"phase": func(p *corev1.Pod) { p.Status.Phase = corev1.PodFailed },
"reason": func(p *corev1.Pod) { p.Status.Reason = "Evicted" },
"message": func(p *corev1.Pod) { p.Status.Message = "evicted" },
"uid": func(p *corev1.Pod) { p.UID = types.UID("other") },
"deletion timestamp": func(p *corev1.Pod) {
p.DeletionTimestamp = &metav1.Time{Time: time.Now()}
},
"runner container terminated": func(p *corev1.Pod) {
p.Status.ContainerStatuses[0].State = corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 1}}
},
"runner container exit code": func(p *corev1.Pod) {
p.Status.ContainerStatuses[0].State = corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 7}}
},
"runner container readiness": func(p *corev1.Pod) {
p.Status.ContainerStatuses[0].Ready = false
},
"init container status": func(p *corev1.Pod) {
p.Status.InitContainerStatuses[0].State = corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 1}}
},
"ready condition": func(p *corev1.Pod) {
p.Status.Conditions[0].Status = corev1.ConditionFalse
},
} {
t.Run("reconciles on "+name, func(t *testing.T) {
old, updated := base(), base()
mutate(updated)
assert.True(t, ephemeralRunnerOwnedPodPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
}
t.Run("ignores pod noise the runner never reads", func(t *testing.T) {
old, updated := base(), base()
updated.ResourceVersion = "2"
updated.Labels = map[string]string{"a": "b"}
updated.Status.PodIP = "10.0.0.1"
updated.Status.PodIPs = []corev1.PodIP{{IP: "10.0.0.1"}}
updated.Status.HostIP = "10.0.0.2"
updated.Status.StartTime = &metav1.Time{Time: time.Now()}
updated.Status.Conditions = append(updated.Status.Conditions,
corev1.PodCondition{Type: corev1.PodScheduled, Status: corev1.ConditionTrue},
corev1.PodCondition{Type: corev1.ContainersReady, Status: corev1.ConditionTrue},
)
assert.False(t, ephemeralRunnerOwnedPodPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
t.Run("ready condition transition time alone does not reconcile", func(t *testing.T) {
old, updated := base(), base()
updated.Status.Conditions[0].LastTransitionTime = metav1.NewTime(time.Now().Add(time.Hour))
assert.False(t, ephemeralRunnerOwnedPodPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
t.Run("reconciles on unexpected types", func(t *testing.T) {
assert.True(t, ephemeralRunnerOwnedPodPredicate().Update(event.UpdateEvent{
ObjectOld: &v1alpha1.EphemeralRunner{},
ObjectNew: &v1alpha1.EphemeralRunner{},
}))
})
}
func TestAutoscalingListenerOwnedPodPredicate(t *testing.T) {
base := func() *corev1.Pod {
return &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "listener",
Namespace: "default",
Generation: 1,
Labels: map[string]string{"app": "listener"},
Annotations: map[string]string{AnnotationKeyListenerConfigResourceVersion: "1"},
},
Spec: corev1.PodSpec{
ServiceAccountName: "listener",
Containers: []corev1.Container{
{
Name: autoscalingListenerContainerName,
Image: "listener:1",
},
},
},
Status: corev1.PodStatus{
Phase: corev1.PodRunning,
ContainerStatuses: []corev1.ContainerStatus{
{
Name: autoscalingListenerContainerName,
State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}},
},
},
},
}
}
// Every listener pod field the AutoscalingListener reconciler branches on has
// to wake it up.
for name, mutate := range map[string]func(*corev1.Pod){
"phase": func(p *corev1.Pod) { p.Status.Phase = corev1.PodFailed },
"reason": func(p *corev1.Pod) { p.Status.Reason = "Evicted" },
"message": func(p *corev1.Pod) { p.Status.Message = "evicted" },
"labels": func(p *corev1.Pod) { p.Labels["app"] = "changed" },
"listener config resource version": func(p *corev1.Pod) {
p.Annotations[AnnotationKeyListenerConfigResourceVersion] = "2"
},
"deletion timestamp": func(p *corev1.Pod) {
p.DeletionTimestamp = &metav1.Time{Time: time.Now()}
},
"generation": func(p *corev1.Pod) { p.Generation = 2 },
"owner references": func(p *corev1.Pod) { p.OwnerReferences = []metav1.OwnerReference{{Name: "owner"}} },
"finalizers": func(p *corev1.Pod) { p.Finalizers = []string{"finalizer"} },
"image": func(p *corev1.Pod) { p.Spec.Containers[0].Image = "listener:2" },
"container ports": func(p *corev1.Pod) {
p.Spec.Containers[0].Ports = []corev1.ContainerPort{{ContainerPort: 8080}}
},
"service account name": func(p *corev1.Pod) { p.Spec.ServiceAccountName = "other" },
"listener container terminated": func(p *corev1.Pod) {
p.Status.ContainerStatuses[0].State = corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 1}}
},
} {
t.Run("reconciles on "+name, func(t *testing.T) {
old, updated := base(), base()
mutate(updated)
assert.True(t, autoscalingListenerOwnedPodPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
}
t.Run("ignores pod noise the listener never reads", func(t *testing.T) {
old, updated := base(), base()
updated.ResourceVersion = "2"
updated.Status.PodIP = "10.0.0.1"
updated.Status.PodIPs = []corev1.PodIP{{IP: "10.0.0.1"}}
updated.Status.HostIP = "10.0.0.2"
updated.Status.StartTime = &metav1.Time{Time: time.Now()}
updated.Status.NominatedNodeName = "node"
updated.Status.QOSClass = corev1.PodQOSBestEffort
updated.Status.Conditions = []corev1.PodCondition{
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
{Type: corev1.PodScheduled, Status: corev1.ConditionTrue},
}
updated.Status.InitContainerStatuses = []corev1.ContainerStatus{
{Name: "init", State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 0}}},
}
assert.False(t, autoscalingListenerOwnedPodPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
t.Run("reconciles on unexpected types", func(t *testing.T) {
assert.True(t, autoscalingListenerOwnedPodPredicate().Update(event.UpdateEvent{
ObjectOld: &v1alpha1.AutoscalingListener{},
ObjectNew: &v1alpha1.AutoscalingListener{},
}))
})
t.Run("does not filter create, delete or generic events", func(t *testing.T) {
p := autoscalingListenerOwnedPodPredicate()
assert.True(t, p.Create(event.CreateEvent{Object: base()}))
assert.True(t, p.Delete(event.DeleteEvent{Object: base()}))
assert.True(t, p.Generic(event.GenericEvent{Object: base()}))
})
}
func TestAutoscalingListenerOwnedServiceAccountPredicate(t *testing.T) {
base := func() *corev1.ServiceAccount {
return &corev1.ServiceAccount{
ObjectMeta: metav1.ObjectMeta{
Name: "listener",
Namespace: "default",
Generation: 1,
Labels: map[string]string{"app": "listener"},
Annotations: map[string]string{"annotation": "1"},
},
}
}
// The reconciler merges labels and annotations back onto the service account,
// so a change to either has to wake it up.
for name, mutate := range map[string]func(*corev1.ServiceAccount){
"labels": func(sa *corev1.ServiceAccount) { sa.Labels["app"] = "changed" },
"annotations": func(sa *corev1.ServiceAccount) { sa.Annotations["annotation"] = "2" },
"deletion timestamp": func(sa *corev1.ServiceAccount) {
sa.DeletionTimestamp = &metav1.Time{Time: time.Now()}
},
"finalizers": func(sa *corev1.ServiceAccount) { sa.Finalizers = []string{"finalizer"} },
"owner references": func(sa *corev1.ServiceAccount) { sa.OwnerReferences = []metav1.OwnerReference{{Name: "owner"}} },
} {
t.Run("reconciles on "+name, func(t *testing.T) {
old, updated := base(), base()
mutate(updated)
assert.True(t, autoscalingListenerOwnedServiceAccountPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
}
t.Run("ignores service account noise the listener never reads", func(t *testing.T) {
old, updated := base(), base()
updated.ResourceVersion = "2"
updated.Secrets = []corev1.ObjectReference{{Name: "listener-token"}}
updated.ImagePullSecrets = []corev1.LocalObjectReference{{Name: "pull"}}
automount := true
updated.AutomountServiceAccountToken = &automount
assert.False(t, autoscalingListenerOwnedServiceAccountPredicate().Update(event.UpdateEvent{ObjectOld: old, ObjectNew: updated}))
})
t.Run("reconciles on unexpected types", func(t *testing.T) {
assert.True(t, autoscalingListenerOwnedServiceAccountPredicate().Update(event.UpdateEvent{
ObjectOld: &corev1.Pod{},
ObjectNew: &corev1.Pod{},
}))
})
}
// The predicates above are projections of the status fields their reconcilers
// read, so a field added to either status has to be classified: either it wakes
// the reconciler up and belongs in the tables above, or it is deliberately
// ignored. Nothing else forces that decision, so the field names are pinned
// here and adding one fails until somebody updates this list and the tables.
//
// The pin is a tripwire on the type, not a proof of correspondence: it cannot
// tell whether a reconciler started branching on a field the matching predicate
// still drops. Only the tables above assert the behaviour.
func TestPredicateProjectionsCoverEveryStatusField(t *testing.T) {
fieldNames := func(v any) []string {
typ := reflect.TypeOf(v)
names := make([]string, 0, typ.NumField())
for i := 0; i < typ.NumField(); i++ {
names = append(names, typ.Field(i).Name)
}
sort.Strings(names)
return names
}
t.Run("ephemeral runner set status", func(t *testing.T) {
assert.Equal(t, []string{
"AppliedActionableRevision",
"FinishedRunnerCleanupPatchID",
"Phase",
}, fieldNames(v1alpha1.EphemeralRunnerSetStatus{}))
})
t.Run("ephemeral runner status", func(t *testing.T) {
assert.Equal(t, []string{
"Failures",
"JobDisplayName",
"JobID",
"JobRepositoryName",
"JobRequestID",
"JobWorkflowRef",
"Message",
"Phase",
"Ready",
"Reason",
"RunnerID",
"RunnerName",
"WorkflowRunID",
}, fieldNames(v1alpha1.EphemeralRunnerStatus{}))
})
}