Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions charts/gha-runner-scale-set/templates/manager_role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ rules:
- create
- delete
- get
- patch
- apiGroups:
- ""
resources:
Expand Down
9 changes: 9 additions & 0 deletions controllers/actions.github.com/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ const DefaultScaleSetListenerLogFormat = string(logging.LogFormatText)
// ownerKey is field selector matching the owner name of a particular resource
const resourceOwnerKey = ".metadata.controller"

// Labels and annotations applied to runner pods when a job is assigned
const (
LabelKeyGitHubJobRepository = "actions.github.com/job-repository"
LabelKeyGitHubJobDisplayName = "actions.github.com/job-display-name"
AnnotationKeyGitHubJobID = "actions.github.com/job-id"
AnnotationKeyWorkflowRunID = "actions.github.com/workflow-run-id"
AnnotationKeyGitHubJobRepository = "actions.github.com/job-repository-name"
)

// EphemeralRunner pod creation failure reasons
const (
ReasonTooManyPodFailures = "TooManyPodFailures"
Expand Down
41 changes: 41 additions & 0 deletions controllers/actions.github.com/ephemeralrunner_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ
}
}

if err := r.labelPodWithJobInfo(ctx, ephemeralRunner, pod, log); err != nil {
log.Error(err, "Failed to label pod with job info")
return ctrl.Result{}, err
}

cs := runnerContainerStatus(pod)
switch {
case pod.Status.Phase == corev1.PodFailed: // All containers are stopped
Expand Down Expand Up @@ -832,6 +837,42 @@ func (r *EphemeralRunnerReconciler) updateRunStatusFromPod(ctx context.Context,
return nil
}

func (r *EphemeralRunnerReconciler) labelPodWithJobInfo(ctx context.Context, ephemeralRunner *v1alpha1.EphemeralRunner, pod *corev1.Pod, log logr.Logger) error {
if ephemeralRunner.Status.JobRepositoryName == "" {
return nil
}

if pod.Labels[LabelKeyGitHubJobRepository] != "" {
return nil
}

log.Info("Patching pod with job info labels",
"jobRepository", ephemeralRunner.Status.JobRepositoryName,
"jobDisplayName", ephemeralRunner.Status.JobDisplayName,
)

podPatch := client.MergeFrom(pod.DeepCopy())
if pod.Labels == nil {
pod.Labels = make(map[string]string)
}
if pod.Annotations == nil {
pod.Annotations = make(map[string]string)
}

pod.Labels[LabelKeyGitHubJobRepository] = sanitizeLabelValue(ephemeralRunner.Status.JobRepositoryName)
pod.Labels[LabelKeyGitHubJobDisplayName] = sanitizeLabelValue(ephemeralRunner.Status.JobDisplayName)
pod.Annotations[AnnotationKeyGitHubJobID] = ephemeralRunner.Status.JobID
pod.Annotations[AnnotationKeyWorkflowRunID] = strconv.FormatInt(ephemeralRunner.Status.WorkflowRunID, 10)
pod.Annotations[AnnotationKeyGitHubJobRepository] = ephemeralRunner.Status.JobRepositoryName

if err := r.Patch(ctx, pod, podPatch); err != nil {
return fmt.Errorf("failed to patch pod with job info: %w", err)
}

log.Info("Patched pod with job info labels")
return nil
}

func (r *EphemeralRunnerReconciler) deleteRunnerFromService(ctx context.Context, ephemeralRunner *v1alpha1.EphemeralRunner, log logr.Logger) error {
client, err := r.GetActionsService(ctx, ephemeralRunner)
if err != nil {
Expand Down
164 changes: 164 additions & 0 deletions controllers/actions.github.com/ephemeralrunner_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,170 @@ var _ = Describe("EphemeralRunner", func() {
}
})

It("It should label the pod with job info when a job is assigned", func() {
pod := new(corev1.Pod)
Eventually(
func() (bool, error) {
err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, pod)
if err != nil {
return false, err
}
return true, nil
},
ephemeralRunnerTimeout,
ephemeralRunnerInterval,
).Should(BeEquivalentTo(true))

// Simulate the listener patching job info onto the EphemeralRunner status
Eventually(func() error {
er := new(v1alpha1.EphemeralRunner)
if err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, er); err != nil {
return err
}
er.Status.JobID = "12345"
er.Status.JobRepositoryName = "myorg/my-repo"
er.Status.JobDisplayName = "build"
er.Status.WorkflowRunID = 67890
return k8sClient.Status().Update(ctx, er)
}, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(Succeed(), "failed to update ephemeral runner status with job info")

// Set pod to running with a container status so the reconciler enters the running path
podCopy := pod.DeepCopy()
pod.Status.Phase = corev1.PodRunning
pod.Status.ContainerStatuses = []corev1.ContainerStatus{
{
Name: v1alpha1.EphemeralRunnerContainerName,
State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}},
},
}
err := k8sClient.Status().Patch(ctx, pod, client.MergeFrom(podCopy))
Expect(err).To(BeNil(), "failed to patch pod status to running")

// Verify pod gets labeled with job info (slash replaced with underscore)
Eventually(
func() (string, error) {
updatedPod := new(corev1.Pod)
err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, updatedPod)
if err != nil {
return "", err
}
return updatedPod.Labels[LabelKeyGitHubJobRepository], nil
},
ephemeralRunnerTimeout,
ephemeralRunnerInterval,
).Should(BeEquivalentTo("myorg_my-repo"))

// Verify all labels and annotations
updatedPod := new(corev1.Pod)
err = k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, updatedPod)
Expect(err).To(BeNil())
Expect(updatedPod.Labels[LabelKeyGitHubJobDisplayName]).To(Equal("build"))
Expect(updatedPod.Annotations[AnnotationKeyGitHubJobID]).To(Equal("12345"))
Expect(updatedPod.Annotations[AnnotationKeyWorkflowRunID]).To(Equal("67890"))
Expect(updatedPod.Annotations[AnnotationKeyGitHubJobRepository]).To(Equal("myorg/my-repo"))
})

It("It should not label the pod when no job is assigned", func() {
pod := new(corev1.Pod)
Eventually(
func() (bool, error) {
err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, pod)
if err != nil {
return false, err
}
return true, nil
},
ephemeralRunnerTimeout,
ephemeralRunnerInterval,
).Should(BeEquivalentTo(true))

// Set pod to running without any job info on the EphemeralRunner
podCopy := pod.DeepCopy()
pod.Status.Phase = corev1.PodRunning
pod.Status.ContainerStatuses = []corev1.ContainerStatus{
{
Name: v1alpha1.EphemeralRunnerContainerName,
State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}},
},
}
err := k8sClient.Status().Patch(ctx, pod, client.MergeFrom(podCopy))
Expect(err).To(BeNil(), "failed to patch pod status to running")

// Wait for the reconciler to process the pod status update
Eventually(
func() (v1alpha1.EphemeralRunnerPhase, error) {
updated := new(v1alpha1.EphemeralRunner)
err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, updated)
if err != nil {
return "", err
}
return updated.Status.Phase, nil
},
ephemeralRunnerTimeout,
ephemeralRunnerInterval,
).Should(BeEquivalentTo(corev1.PodRunning))

// Verify pod does NOT have job labels
updatedPod := new(corev1.Pod)
err = k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, updatedPod)
Expect(err).To(BeNil())
Expect(updatedPod.Labels[LabelKeyGitHubJobRepository]).To(BeEmpty())
Expect(updatedPod.Labels[LabelKeyGitHubJobDisplayName]).To(BeEmpty())
Expect(updatedPod.Annotations[AnnotationKeyGitHubJobID]).To(BeEmpty())
})

It("It should truncate long job label values", func() {
pod := new(corev1.Pod)
Eventually(
func() (bool, error) {
err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, pod)
if err != nil {
return false, err
}
return true, nil
},
ephemeralRunnerTimeout,
ephemeralRunnerInterval,
).Should(BeEquivalentTo(true))

longName := "myorg/this-is-a-very-long-repository-name-that-exceeds-sixty-three-characters-limit"
Eventually(func() error {
er := new(v1alpha1.EphemeralRunner)
if err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, er); err != nil {
return err
}
er.Status.JobID = "99"
er.Status.JobRepositoryName = longName
er.Status.JobDisplayName = "build"
er.Status.WorkflowRunID = 1
return k8sClient.Status().Update(ctx, er)
}, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(Succeed(), "failed to update ephemeral runner status with long job repo name")

podCopy := pod.DeepCopy()
pod.Status.Phase = corev1.PodRunning
pod.Status.ContainerStatuses = []corev1.ContainerStatus{
{
Name: v1alpha1.EphemeralRunnerContainerName,
State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}},
},
}
err := k8sClient.Status().Patch(ctx, pod, client.MergeFrom(podCopy))
Expect(err).To(BeNil(), "failed to patch pod status to running")

Eventually(
func() (string, error) {
updatedPod := new(corev1.Pod)
err := k8sClient.Get(ctx, client.ObjectKey{Name: ephemeralRunner.Name, Namespace: ephemeralRunner.Namespace}, updatedPod)
if err != nil {
return "", err
}
return updatedPod.Labels[LabelKeyGitHubJobRepository], nil
},
ephemeralRunnerTimeout,
ephemeralRunnerInterval,
).Should(Equal(sanitizeLabelValue(longName)))
})

It("It should update ready based on the latest condition", func() {
pod := new(corev1.Pod)
Eventually(func() (bool, error) {
Expand Down
12 changes: 12 additions & 0 deletions controllers/actions.github.com/resourcebuilder.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"maps"
"math"
"net"
"regexp"
"strconv"
"strings"

Expand Down Expand Up @@ -837,6 +838,17 @@ func trimLabelValue(val string) string {
return strings.Trim(val, "-_.")
}

var invalidLabelChars = regexp.MustCompile(`[^a-zA-Z0-9\-_.]`)
var invalidLabelEdges = regexp.MustCompile(`^[^a-zA-Z0-9]+|[^a-zA-Z0-9]+$`)

func sanitizeLabelValue(val string) string {
val = invalidLabelChars.ReplaceAllString(val, "_")
if len(val) > 63 {
val = val[:63]
}
return invalidLabelEdges.ReplaceAllString(val, "")
}

func (b *ResourceBuilder) filterAndMergeLabels(base, overwrite map[string]string) map[string]string {
if base == nil && overwrite == nil {
return nil
Expand Down
27 changes: 27 additions & 0 deletions controllers/actions.github.com/resourcebuilder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,33 @@ func TestGitHubURLTrimLabelValues(t *testing.T) {
})
}

func TestSanitizeLabelValue(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"simple", "my-repo", "my-repo"},
{"slashes replaced", "myorg/my-repo", "myorg_my-repo"},
{"spaces replaced", "Test default-runners", "Test_default-runners"},
{"multiple invalid chars", "org/repo name (test)", "org_repo_name__test"},
{"leading invalid stripped", "---my-repo", "my-repo"},
{"trailing invalid stripped", "my-repo...", "my-repo"},
{"all invalid chars", "///", ""},
{"empty string", "", ""},
{"truncated to 63", strings.Repeat("a", 100), strings.Repeat("a", 63)},
{"truncated trailing invalid stripped", strings.Repeat("a", 60) + "/..", strings.Repeat("a", 60)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeLabelValue(tt.input)
assert.Equal(t, tt.expected, got)
assert.LessOrEqual(t, len(got), 63)
})
}
}

func TestOwnershipRelationships(t *testing.T) {
// Create an AutoscalingRunnerSet
autoscalingRunnerSet := v1alpha1.AutoscalingRunnerSet{
Expand Down
2 changes: 0 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ require (
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/gonvenience/bunt v1.4.3 // indirect
github.com/gonvenience/idem v0.0.3 // indirect
Expand All @@ -126,7 +125,6 @@ require (
github.com/gonvenience/ytbx v1.4.8 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/google/gnostic-models v0.7.1 // indirect
github.com/google/go-github/v75 v75.0.0 // indirect
github.com/google/go-github/v84 v84.0.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc // indirect
Expand Down
Loading