Skip to content

Commit 84b08ad

Browse files
authored
[codex] Add ephemeral GitHub runner job spec (#29)
* test: cover org github runner provider preflight * feat: add org github runner provider lifecycle * test: cover ephemeral github runner job operation * feat: add ephemeral github runner job spec * fix: enforce ephemeral runner timeouts
1 parent 734ba11 commit 84b08ad

7 files changed

Lines changed: 555 additions & 0 deletions

File tree

‎.goreleaser.yaml‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,26 @@ builds:
3636
goarch:
3737
- amd64
3838
- arm64
39+
- id: github-actions-runner-job
40+
main: ./cmd/github-actions-runner-job
41+
binary: github-actions-runner-job
42+
env:
43+
- CGO_ENABLED=0
44+
- GOPRIVATE=github.com/GoCodeAlone/*
45+
goos:
46+
- linux
47+
- darwin
48+
- windows
49+
goarch:
50+
- amd64
51+
- arm64
3952

4053
archives:
4154
- id: workflow-plugin-github
4255
ids:
4356
- workflow-plugin-github
4457
- github-runner-provider
58+
- github-actions-runner-job
4559
name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
4660
files:
4761
- plugin.json
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"flag"
6+
"fmt"
7+
"os"
8+
9+
"github.com/GoCodeAlone/workflow-plugin-github/internal"
10+
)
11+
12+
func main() {
13+
if err := run(); err != nil {
14+
fmt.Fprintf(os.Stderr, "github-actions-runner-job failed: %v\n", err)
15+
os.Exit(1)
16+
}
17+
}
18+
19+
func run() error {
20+
specOnly := flag.Bool("spec", false, "emit the deterministic runner spec without starting a runner")
21+
flag.Parse()
22+
if !*specOnly {
23+
return fmt.Errorf("runner execution is not available without a host-provided execution driver; use --spec only for preflight/spec generation")
24+
}
25+
26+
var req internal.EphemeralRunnerJobRequest
27+
decoder := json.NewDecoder(os.Stdin)
28+
decoder.DisallowUnknownFields()
29+
if err := decoder.Decode(&req); err != nil {
30+
return fmt.Errorf("decode request: %w", err)
31+
}
32+
spec, err := internal.BuildEphemeralRunnerJobSpec(req)
33+
if err != nil {
34+
return err
35+
}
36+
return json.NewEncoder(os.Stdout).Encode(map[string]any{
37+
"runner_name": spec.RunnerName,
38+
"labels": spec.Labels,
39+
"runner_group": spec.RunnerGroup,
40+
})
41+
}

‎internal/ephemeral_runner_job.go‎

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package internal
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"time"
9+
)
10+
11+
var ErrEphemeralRunnerCapabilityUnsupported = errors.New("ephemeral runner capability unsupported")
12+
13+
const ephemeralRunnerCleanupTimeout = 30 * time.Second
14+
15+
type EphemeralRunnerJobMode string
16+
17+
const (
18+
EphemeralRunnerJobModeDispatchThenWait EphemeralRunnerJobMode = "dispatch_then_wait"
19+
EphemeralRunnerJobModeAttachToQueued EphemeralRunnerJobMode = "attach_to_queued"
20+
)
21+
22+
type EphemeralRunnerJobRequest struct {
23+
Mode EphemeralRunnerJobMode `json:"mode"`
24+
Environment string `json:"environment"`
25+
OS string `json:"os"`
26+
WorkerID string `json:"worker_id"`
27+
TaskID string `json:"task_id"`
28+
Organization string `json:"organization"`
29+
Repository string `json:"repository,omitempty"`
30+
Workflow string `json:"workflow,omitempty"`
31+
Ref string `json:"ref,omitempty"`
32+
RunnerGroup string `json:"runner_group,omitempty"`
33+
Timeout time.Duration `json:"-"`
34+
RequiredRuntimeCaps []string `json:"required_runtime_caps,omitempty"`
35+
AdvertisedCaps []string `json:"advertised_caps,omitempty"`
36+
}
37+
38+
type EphemeralRunnerJobSpec struct {
39+
RunnerName string `json:"runner_name"`
40+
Labels []string `json:"labels"`
41+
RunnerGroup string `json:"runner_group,omitempty"`
42+
}
43+
44+
type EphemeralRunnerJobResult struct {
45+
RunnerID int64 `json:"runner_id"`
46+
RunnerName string `json:"runner_name"`
47+
Labels []string `json:"labels"`
48+
WorkflowRunID int64 `json:"workflow_run_id"`
49+
WorkflowJobID int64 `json:"workflow_job_id"`
50+
WorkerID string `json:"worker_id"`
51+
TaskID string `json:"task_id"`
52+
ArtifactRefs []string `json:"artifact_refs"`
53+
CleanupStatus string `json:"cleanup_status"`
54+
RedactedError string `json:"redacted_error,omitempty"`
55+
}
56+
57+
type EphemeralRunnerJobDriver interface {
58+
OrgRegistrationToken(ctx context.Context, organization string) (GitHubRunnerRegistrationToken, error)
59+
RunGitHubJob(ctx context.Context, mode EphemeralRunnerJobMode, spec EphemeralRunnerJobSpec, token GitHubRunnerRegistrationToken) (EphemeralRunnerJobResult, error)
60+
RemoveOrgRunner(ctx context.Context, organization string, runnerID int64) error
61+
}
62+
63+
type EphemeralRunnerJob struct {
64+
driver EphemeralRunnerJobDriver
65+
}
66+
67+
func NewEphemeralRunnerJob(driver EphemeralRunnerJobDriver) *EphemeralRunnerJob {
68+
return &EphemeralRunnerJob{driver: driver}
69+
}
70+
71+
func BuildEphemeralRunnerJobSpec(req EphemeralRunnerJobRequest) (EphemeralRunnerJobSpec, error) {
72+
environment := strings.ToLower(strings.TrimSpace(req.Environment))
73+
if environment == "" {
74+
return EphemeralRunnerJobSpec{}, errors.New("environment is required")
75+
}
76+
osName := strings.ToLower(strings.TrimSpace(req.OS))
77+
if osName == "" {
78+
return EphemeralRunnerJobSpec{}, errors.New("os is required")
79+
}
80+
worker := shortEphemeralID(req.WorkerID)
81+
if worker == "" {
82+
return EphemeralRunnerJobSpec{}, errors.New("worker_id is required")
83+
}
84+
task := shortEphemeralID(req.TaskID)
85+
if task == "" {
86+
return EphemeralRunnerJobSpec{}, errors.New("task_id is required")
87+
}
88+
name := fmt.Sprintf("wfc-%s-ghp-%s-%s-%s", environment, osName, worker, task)
89+
return EphemeralRunnerJobSpec{
90+
RunnerName: name,
91+
RunnerGroup: strings.TrimSpace(req.RunnerGroup),
92+
Labels: []string{
93+
"self-hosted",
94+
osName,
95+
name,
96+
"wfc-ghp-" + environment,
97+
"wfc-ghp-ephemeral",
98+
},
99+
}, nil
100+
}
101+
102+
func (j *EphemeralRunnerJob) Run(ctx context.Context, req EphemeralRunnerJobRequest) (EphemeralRunnerJobResult, error) {
103+
if err := requireEphemeralRunnerCapabilities(req.RequiredRuntimeCaps, req.AdvertisedCaps); err != nil {
104+
return EphemeralRunnerJobResult{}, err
105+
}
106+
if j == nil || j.driver == nil {
107+
return EphemeralRunnerJobResult{}, errors.New("ephemeral runner job driver is required")
108+
}
109+
spec, err := BuildEphemeralRunnerJobSpec(req)
110+
if err != nil {
111+
return EphemeralRunnerJobResult{}, err
112+
}
113+
jobCtx := ctx
114+
cancel := func() {}
115+
if req.Timeout > 0 {
116+
jobCtx, cancel = context.WithTimeout(ctx, req.Timeout)
117+
}
118+
defer cancel()
119+
token, err := j.driver.OrgRegistrationToken(jobCtx, req.Organization)
120+
if err != nil {
121+
return EphemeralRunnerJobResult{}, err
122+
}
123+
result, runErr := j.driver.RunGitHubJob(jobCtx, req.Mode, spec, token)
124+
if result.RunnerID > 0 {
125+
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), ephemeralRunnerCleanupTimeout)
126+
defer cleanupCancel()
127+
if err := j.driver.RemoveOrgRunner(cleanupCtx, req.Organization, result.RunnerID); err != nil {
128+
result.CleanupStatus = "remove_failed"
129+
if runErr == nil {
130+
runErr = err
131+
}
132+
} else {
133+
result.CleanupStatus = "removed"
134+
}
135+
} else if result.CleanupStatus == "" {
136+
result.CleanupStatus = "skipped"
137+
}
138+
fillEphemeralRunnerResult(&result, req, spec)
139+
return result, runErr
140+
}
141+
142+
func fillEphemeralRunnerResult(result *EphemeralRunnerJobResult, req EphemeralRunnerJobRequest, spec EphemeralRunnerJobSpec) {
143+
if result.RunnerName == "" {
144+
result.RunnerName = spec.RunnerName
145+
}
146+
if len(result.Labels) == 0 {
147+
result.Labels = append([]string(nil), spec.Labels...)
148+
}
149+
if result.WorkerID == "" {
150+
result.WorkerID = req.WorkerID
151+
}
152+
if result.TaskID == "" {
153+
result.TaskID = req.TaskID
154+
}
155+
}
156+
157+
func requireEphemeralRunnerCapabilities(required, advertised []string) error {
158+
available := map[string]struct{}{}
159+
for _, cap := range advertised {
160+
cap = strings.ToLower(strings.TrimSpace(cap))
161+
if cap != "" {
162+
available[cap] = struct{}{}
163+
}
164+
}
165+
for _, cap := range required {
166+
cap = strings.ToLower(strings.TrimSpace(cap))
167+
if cap == "" {
168+
continue
169+
}
170+
if _, ok := available[cap]; !ok {
171+
return fmt.Errorf("%w: %s", ErrEphemeralRunnerCapabilityUnsupported, cap)
172+
}
173+
}
174+
return nil
175+
}
176+
177+
func shortEphemeralID(value string) string {
178+
value = strings.TrimSpace(value)
179+
if value == "" {
180+
return ""
181+
}
182+
if _, suffix, ok := strings.Cut(value, "-"); ok && suffix != "" {
183+
value = suffix
184+
}
185+
if len(value) > 8 {
186+
return value[:8]
187+
}
188+
return value
189+
}

0 commit comments

Comments
 (0)