Skip to content

Commit 86a5bed

Browse files
committed
fix: clean up runners after terminal jobs
1 parent ec1febe commit 86a5bed

9 files changed

Lines changed: 267 additions & 0 deletions

File tree

‎apis/actions.github.com/v1alpha1/ephemeralrunner_types.go‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,21 @@ type EphemeralRunnerStatus struct {
183183

184184
// +optional
185185
JobDisplayName string `json:"jobDisplayName,omitempty"`
186+
187+
// JobCompletion records the terminal job event received for this runner.
188+
// +optional
189+
JobCompletion *EphemeralRunnerJobCompletion `json:"jobCompletion,omitempty"`
190+
}
191+
192+
// EphemeralRunnerJobCompletion identifies the terminal job event received for
193+
// an ephemeral runner. The controller uses all identity fields before deleting
194+
// a runner whose process did not exit after the job became terminal.
195+
type EphemeralRunnerJobCompletion struct {
196+
Result string `json:"result"`
197+
RunnerID int `json:"runnerId"`
198+
JobID string `json:"jobId"`
199+
WorkflowRunID int64 `json:"workflowRunId"`
200+
FinishedAt metav1.Time `json:"finishedAt"`
186201
}
187202

188203
// EphemeralRunnerPhase is the phase of the ephemeral runner.

‎apis/actions.github.com/v1alpha1/zz_generated.deepcopy.go‎

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎charts/gha-runner-scale-set-controller-experimental/crds/actions.github.com_ephemeralrunners.yaml‎

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎charts/gha-runner-scale-set-controller/crds/actions.github.com_ephemeralrunners.yaml‎

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cmd/ghalistener/scaler/scaler.go‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/actions/scaleset/listener"
1313
jsonpatch "github.com/evanphx/json-patch"
1414
kerrors "k8s.io/apimachinery/pkg/api/errors"
15+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
1516
"k8s.io/apimachinery/pkg/types"
1617
"k8s.io/client-go/kubernetes"
1718
"k8s.io/client-go/rest"
@@ -156,6 +157,59 @@ func (w *Scaler) HandleJobStarted(ctx context.Context, jobInfo *scaleset.JobStar
156157

157158
func (w *Scaler) HandleJobCompleted(ctx context.Context, msg *scaleset.JobCompleted) error {
158159
w.dirty = true
160+
161+
w.logger.Info("Recording completed job for the runner",
162+
"runnerName", msg.RunnerName,
163+
"runnerId", msg.RunnerID,
164+
"jobId", msg.JobID,
165+
"workflowRunId", msg.WorkflowRunID,
166+
"result", msg.Result,
167+
"finishedAt", msg.FinishTime)
168+
169+
original, err := json.Marshal(&v1alpha1.EphemeralRunner{})
170+
if err != nil {
171+
return fmt.Errorf("failed to marshal empty ephemeral runner: %w", err)
172+
}
173+
174+
patch, err := json.Marshal(&v1alpha1.EphemeralRunner{
175+
Status: v1alpha1.EphemeralRunnerStatus{
176+
JobCompletion: &v1alpha1.EphemeralRunnerJobCompletion{
177+
Result: msg.Result,
178+
RunnerID: msg.RunnerID,
179+
JobID: msg.JobID,
180+
WorkflowRunID: msg.WorkflowRunID,
181+
FinishedAt: metav1.NewTime(msg.FinishTime),
182+
},
183+
},
184+
})
185+
if err != nil {
186+
return fmt.Errorf("failed to marshal ephemeral runner completion patch: %w", err)
187+
}
188+
189+
mergePatch, err := jsonpatch.CreateMergePatch(original, patch)
190+
if err != nil {
191+
return fmt.Errorf("failed to create completion merge patch for ephemeral runner: %w", err)
192+
}
193+
194+
patchedStatus := &v1alpha1.EphemeralRunner{}
195+
err = w.clientset.RESTClient().
196+
Patch(types.MergePatchType).
197+
Prefix("apis", v1alpha1.GroupVersion.Group, v1alpha1.GroupVersion.Version).
198+
Namespace(w.config.EphemeralRunnerSetNamespace).
199+
Resource("EphemeralRunners").
200+
Name(msg.RunnerName).
201+
SubResource("status").
202+
Body(mergePatch).
203+
Do(ctx).
204+
Into(patchedStatus)
205+
if err != nil {
206+
if kerrors.IsNotFound(err) {
207+
w.logger.Info("Ephemeral runner not found, skipping completed job status patch", "runnerName", msg.RunnerName)
208+
return nil
209+
}
210+
return fmt.Errorf("could not patch completed job status, patch JSON: %s, error: %w", string(mergePatch), err)
211+
}
212+
159213
return nil
160214
}
161215

‎config/crd/bases/actions.github.com_ephemeralrunners.yaml‎

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎controllers/actions.github.com/ephemeralrunner_controller.go‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import (
4444
const (
4545
ephemeralRunnerFinalizerName = "ephemeralrunner.actions.github.com/finalizer"
4646
ephemeralRunnerActionsFinalizerName = "ephemeralrunner.actions.github.com/runner-registration-finalizer"
47+
completedJobRunnerGracePeriod = 30 * time.Second
4748
)
4849

4950
// EphemeralRunnerReconciler reconciles a EphemeralRunner object
@@ -186,6 +187,29 @@ func (r *EphemeralRunnerReconciler) Reconcile(ctx context.Context, req ctrl.Requ
186187
log.Info("Successfully added finalizers")
187188
}
188189

190+
if completion := ephemeralRunner.Status.JobCompletion; completion != nil {
191+
if !jobCompletionMatchesRunner(&ephemeralRunner) {
192+
log.Info("Ignoring completed job event that does not match the ephemeral runner status",
193+
"completionRunnerId", completion.RunnerID,
194+
"completionJobId", completion.JobID,
195+
"completionWorkflowRunId", completion.WorkflowRunID)
196+
} else {
197+
deleteAfter := completion.FinishedAt.Add(completedJobRunnerGracePeriod)
198+
if wait := time.Until(deleteAfter); wait > 0 {
199+
log.Info("Waiting briefly for the runner process to exit after job completion", "requeueAfter", wait)
200+
return ctrl.Result{RequeueAfter: wait}, nil
201+
}
202+
203+
log.Info("Job is terminal but runner is still present; issuing delete",
204+
"result", completion.Result,
205+
"finishedAt", completion.FinishedAt)
206+
if err := r.Delete(ctx, &ephemeralRunner); client.IgnoreNotFound(err) != nil {
207+
return ctrl.Result{}, fmt.Errorf("failed to delete ephemeral runner after terminal job: %w", err)
208+
}
209+
return ctrl.Result{}, nil
210+
}
211+
}
212+
189213
secret := new(corev1.Secret)
190214
if err := r.Get(ctx, req.NamespacedName, secret); err != nil {
191215
if !kerrors.IsNotFound(err) {
@@ -971,6 +995,18 @@ func runnerContainerStatus(pod *corev1.Pod) *corev1.ContainerStatus {
971995
return nil
972996
}
973997

998+
func jobCompletionMatchesRunner(runner *v1alpha1.EphemeralRunner) bool {
999+
completion := runner.Status.JobCompletion
1000+
if completion == nil || completion.Result == "" || completion.JobID == "" || completion.FinishedAt.IsZero() {
1001+
return false
1002+
}
1003+
1004+
return completion.RunnerID == runner.Status.RunnerID &&
1005+
runner.Status.RunnerName == runner.Name &&
1006+
completion.JobID == runner.Status.JobID &&
1007+
completion.WorkflowRunID == runner.Status.WorkflowRunID
1008+
}
1009+
9741010
func initContainerFailed(pod *corev1.Pod) bool {
9751011
for i := range pod.Status.InitContainerStatuses {
9761012
cs := &pod.Status.InitContainerStatuses[i]

‎controllers/actions.github.com/ephemeralrunner_controller_test.go‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,35 @@ var _ = Describe("EphemeralRunner", func() {
185185
).Should(BeEquivalentTo(ephemeralRunner.Name))
186186
})
187187

188+
It("deletes a live runner after receiving its exact terminal job event", func() {
189+
created := new(v1alpha1.EphemeralRunner)
190+
Eventually(func(g Gomega) {
191+
g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ephemeralRunner), created)).To(Succeed())
192+
g.Expect(created.Finalizers).To(ConsistOf(ephemeralRunnerFinalizerName, ephemeralRunnerActionsFinalizerName))
193+
194+
pod := new(corev1.Pod)
195+
g.Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(ephemeralRunner), pod)).To(Succeed())
196+
}, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(Succeed())
197+
198+
created.Status.RunnerID = 2402
199+
created.Status.RunnerName = created.Name
200+
created.Status.JobID = "85cb98eb-2919-5766-872c-cc997f618c1f"
201+
created.Status.WorkflowRunID = 31753891150
202+
created.Status.JobCompletion = &v1alpha1.EphemeralRunnerJobCompletion{
203+
Result: "failed",
204+
RunnerID: created.Status.RunnerID,
205+
JobID: created.Status.JobID,
206+
WorkflowRunID: created.Status.WorkflowRunID,
207+
FinishedAt: metav1.NewTime(time.Now().Add(-completedJobRunnerGracePeriod - time.Second)),
208+
}
209+
Expect(k8sClient.Status().Update(ctx, created)).To(Succeed())
210+
211+
Eventually(func() bool {
212+
err := k8sClient.Get(ctx, client.ObjectKeyFromObject(ephemeralRunner), new(v1alpha1.EphemeralRunner))
213+
return kerrors.IsNotFound(err)
214+
}, ephemeralRunnerTimeout, ephemeralRunnerInterval).Should(BeTrue())
215+
})
216+
188217
It("It should re-create pod on failure and no job assigned", func() {
189218
pod := new(corev1.Pod)
190219
Eventually(func() (bool, error) {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package actionsgithubcom
2+
3+
import (
4+
"testing"
5+
"time"
6+
7+
"github.com/actions/actions-runner-controller/apis/actions.github.com/v1alpha1"
8+
"github.com/stretchr/testify/assert"
9+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
10+
)
11+
12+
func TestJobCompletionMatchesRunner(t *testing.T) {
13+
finishedAt := metav1.NewTime(time.Now())
14+
runner := &v1alpha1.EphemeralRunner{
15+
ObjectMeta: metav1.ObjectMeta{Name: "runner-1"},
16+
Status: v1alpha1.EphemeralRunnerStatus{
17+
RunnerID: 42,
18+
RunnerName: "runner-1",
19+
JobID: "job-1",
20+
WorkflowRunID: 100,
21+
JobCompletion: &v1alpha1.EphemeralRunnerJobCompletion{
22+
Result: "cancelled",
23+
RunnerID: 42,
24+
JobID: "job-1",
25+
WorkflowRunID: 100,
26+
FinishedAt: finishedAt,
27+
},
28+
},
29+
}
30+
31+
assert.True(t, jobCompletionMatchesRunner(runner))
32+
33+
tests := map[string]func(*v1alpha1.EphemeralRunner){
34+
"runner id": func(r *v1alpha1.EphemeralRunner) { r.Status.JobCompletion.RunnerID++ },
35+
"runner name": func(r *v1alpha1.EphemeralRunner) { r.Status.RunnerName = "another-runner" },
36+
"job id": func(r *v1alpha1.EphemeralRunner) { r.Status.JobCompletion.JobID = "another-job" },
37+
"workflow run id": func(r *v1alpha1.EphemeralRunner) { r.Status.JobCompletion.WorkflowRunID++ },
38+
}
39+
for name, mutate := range tests {
40+
t.Run("rejects mismatched "+name, func(t *testing.T) {
41+
candidate := runner.DeepCopy()
42+
mutate(candidate)
43+
assert.False(t, jobCompletionMatchesRunner(candidate))
44+
})
45+
}
46+
}

0 commit comments

Comments
 (0)