|
| 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