|
8 | 8 | "encoding/json" |
9 | 9 | "errors" |
10 | 10 | "fmt" |
| 11 | + "io" |
11 | 12 | "log/slog" |
12 | 13 | "net/http" |
13 | 14 | "os" |
@@ -361,6 +362,73 @@ func getLaunchConfig() (LaunchConfig, error) { |
361 | 362 | }, nil |
362 | 363 | } |
363 | 364 |
|
| 365 | +// JITConfigRequest represents the request body for generating a JIT runner config. |
| 366 | +type JITConfigRequest struct { |
| 367 | + Name string `json:"name"` |
| 368 | + RunnerGroupID int `json:"runner_group_id"` |
| 369 | + Labels []string `json:"labels"` |
| 370 | + WorkFolder string `json:"work_folder"` |
| 371 | +} |
| 372 | + |
| 373 | +// JITConfigResponse represents the response from the JIT config API. |
| 374 | +type JITConfigResponse struct { |
| 375 | + Runner struct { |
| 376 | + ID int `json:"id"` |
| 377 | + Name string `json:"name"` |
| 378 | + } `json:"runner"` |
| 379 | + EncodedJITConfig string `json:"encoded_jit_config"` |
| 380 | +} |
| 381 | + |
| 382 | +// generateJITConfig calls the GitHub API to generate a JIT runner configuration. |
| 383 | +// This eliminates the need for config.sh on the runner, saving 15-30 seconds. |
| 384 | +func generateJITConfig(pat, org, runnerName string, labels []string) (*JITConfigResponse, error) { |
| 385 | + reqBody := JITConfigRequest{ |
| 386 | + Name: runnerName, |
| 387 | + RunnerGroupID: 1, // Default runner group |
| 388 | + Labels: labels, |
| 389 | + WorkFolder: "_work", |
| 390 | + } |
| 391 | + |
| 392 | + jsonBody, err := json.Marshal(reqBody) |
| 393 | + if err != nil { |
| 394 | + return nil, fmt.Errorf("failed to marshal JIT config request: %w", err) |
| 395 | + } |
| 396 | + |
| 397 | + url := fmt.Sprintf("https://api.github.com/orgs/%s/actions/runners/generate-jitconfig", org) |
| 398 | + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody)) |
| 399 | + if err != nil { |
| 400 | + return nil, fmt.Errorf("failed to create request: %w", err) |
| 401 | + } |
| 402 | + |
| 403 | + req.Header.Set("Accept", "application/vnd.github+json") |
| 404 | + req.Header.Set("Authorization", "Bearer "+pat) |
| 405 | + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") |
| 406 | + req.Header.Set("Content-Type", "application/json") |
| 407 | + |
| 408 | + client := &http.Client{Timeout: 30 * time.Second} |
| 409 | + resp, err := client.Do(req) |
| 410 | + if err != nil { |
| 411 | + return nil, fmt.Errorf("failed to call JIT config API: %w", err) |
| 412 | + } |
| 413 | + defer resp.Body.Close() |
| 414 | + |
| 415 | + body, err := io.ReadAll(resp.Body) |
| 416 | + if err != nil { |
| 417 | + return nil, fmt.Errorf("failed to read response body: %w", err) |
| 418 | + } |
| 419 | + |
| 420 | + if resp.StatusCode != http.StatusCreated { |
| 421 | + return nil, fmt.Errorf("JIT config API returned status %d: %s", resp.StatusCode, string(body)) |
| 422 | + } |
| 423 | + |
| 424 | + var jitResp JITConfigResponse |
| 425 | + if err := json.Unmarshal(body, &jitResp); err != nil { |
| 426 | + return nil, fmt.Errorf("failed to parse JIT config response: %w", err) |
| 427 | + } |
| 428 | + |
| 429 | + return &jitResp, nil |
| 430 | +} |
| 431 | + |
364 | 432 | // handleMaintenance processes scheduled warm pool maintenance events. |
365 | 433 | func handleMaintenance() error { |
366 | 434 | slog.Info("warm pool maintenance triggered") |
@@ -534,20 +602,42 @@ func handleWebhook(request events.APIGatewayProxyRequest) (events.APIGatewayProx |
534 | 602 | } |
535 | 603 | } |
536 | 604 |
|
537 | | - slog.Info("processing job", "instanceType", instanceType, "jobID", event.GetWorkflowJob().GetID()) |
| 605 | + jobEventID := event.GetWorkflowJob().GetID() |
| 606 | + runnerName := fmt.Sprintf("ephemeral-i-%d", jobEventID) |
| 607 | + |
| 608 | + slog.Info("processing job", "instanceType", instanceType, "jobID", jobEventID, "runnerName", runnerName) |
| 609 | + |
| 610 | + // Build labels for the runner |
| 611 | + labels := []string{string(instanceType), "ephemeral", "X64"} |
| 612 | + if extraLabels != "" { |
| 613 | + // extraLabels already has leading comma, split and add non-empty labels |
| 614 | + for _, label := range strings.Split(extraLabels, ",") { |
| 615 | + if label = strings.TrimSpace(label); label != "" { |
| 616 | + labels = append(labels, label) |
| 617 | + } |
| 618 | + } |
| 619 | + } |
| 620 | + |
| 621 | + // Generate JIT config from GitHub API (eliminates need for config.sh on instance) |
| 622 | + jitConfig, err := generateJITConfig(pat, "frgrisk", runnerName, labels) |
| 623 | + if err != nil { |
| 624 | + slog.Error("failed to generate JIT config", "error", err.Error()) |
| 625 | + return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err |
| 626 | + } |
| 627 | + |
| 628 | + slog.Info("generated JIT config", "runnerID", jitConfig.Runner.ID, "runnerName", jitConfig.Runner.Name) |
538 | 629 |
|
539 | 630 | tpl, err := template.New("userdata").Parse(userData) |
540 | 631 | if err != nil { |
541 | 632 | return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err |
542 | 633 | } |
543 | 634 |
|
544 | 635 | var buf bytes.Buffer |
545 | | - if err := tpl.Execute(&buf, map[string]string{"GitHubPAT": pat, "ExtraLabels": extraLabels}); err != nil { |
| 636 | + if err := tpl.Execute(&buf, map[string]string{"JITConfig": jitConfig.EncodedJITConfig}); err != nil { |
546 | 637 | return events.APIGatewayProxyResponse{StatusCode: http.StatusInternalServerError}, err |
547 | 638 | } |
548 | 639 |
|
549 | 640 | finalUserData := buf.String() |
550 | | - jobEventID := event.GetWorkflowJob().GetID() |
551 | 641 |
|
552 | 642 | // Get warm pool target size for this instance type |
553 | 643 | poolConfig := parseWarmPoolConfig() |
|
0 commit comments