@@ -19,6 +19,7 @@ import ( |
| 19 | 19 | "github.com/jackc/pgx/v5" |
| 20 | 20 | "github.com/jackc/pgx/v5/pgtype" |
| 21 | 21 | |
| 22 | + "github.com/tenseleyFlow/shithub/internal/actions/finalize" |
| 22 | 23 | "github.com/tenseleyFlow/shithub/internal/actions/runnerlabels" |
| 23 | 24 | "github.com/tenseleyFlow/shithub/internal/actions/runnertoken" |
| 24 | 25 | actionsdb "github.com/tenseleyFlow/shithub/internal/actions/sqlc" |
@@ -27,6 +28,7 @@ import ( |
| 27 | 28 | checksdb "github.com/tenseleyFlow/shithub/internal/checks/sqlc" |
| 28 | 29 | "github.com/tenseleyFlow/shithub/internal/infra/metrics" |
| 29 | 30 | "github.com/tenseleyFlow/shithub/internal/ratelimit" |
| 31 | + "github.com/tenseleyFlow/shithub/internal/worker" |
| 30 | 32 | ) |
| 31 | 33 | |
| 32 | 34 | var runnerHeartbeatLimit = ratelimit.Policy{ |
@@ -38,6 +40,7 @@ var runnerHeartbeatLimit = ratelimit.Policy{ |
| 38 | 40 | func (h *Handlers) mountRunners(r chi.Router) { |
| 39 | 41 | r.Post("/api/v1/runners/heartbeat", h.runnerHeartbeat) |
| 40 | 42 | r.Post("/api/v1/jobs/{id}/logs", h.runnerJobLogs) |
| 43 | + r.Post("/api/v1/jobs/{id}/steps/{step_id}/status", h.runnerStepStatus) |
| 41 | 44 | r.Post("/api/v1/jobs/{id}/status", h.runnerJobStatus) |
| 42 | 45 | r.Post("/api/v1/jobs/{id}/artifacts/upload", h.runnerJobArtifactUpload) |
| 43 | 46 | r.Post("/api/v1/jobs/{id}/cancel-check", h.runnerJobCancelCheck) |
@@ -413,6 +416,43 @@ func (h *Handlers) runnerJobStatus(w http.ResponseWriter, r *http.Request) { |
| 413 | 416 | h.writeNextTokenResponse(w, r, http.StatusOK, auth, bodyMap) |
| 414 | 417 | } |
| 415 | 418 | |
| 419 | +func (h *Handlers) runnerStepStatus(w http.ResponseWriter, r *http.Request) { |
| 420 | + auth, ok := h.authenticateRunnerJob(w, r) |
| 421 | + if !ok { |
| 422 | + return |
| 423 | + } |
| 424 | + stepID, err := strconv.ParseInt(chi.URLParam(r, "step_id"), 10, 64) |
| 425 | + if err != nil || stepID <= 0 { |
| 426 | + writeAPIError(w, http.StatusNotFound, "step not found") |
| 427 | + return |
| 428 | + } |
| 429 | + q := actionsdb.New() |
| 430 | + step, err := q.GetWorkflowStepByID(r.Context(), h.d.Pool, stepID) |
| 431 | + if err != nil || step.JobID != auth.Job.ID { |
| 432 | + writeAPIError(w, http.StatusNotFound, "step not found") |
| 433 | + return |
| 434 | + } |
| 435 | + var body runnerStatusRequest |
| 436 | + if err := decodeJSONBody(r.Body, &body); err != nil { |
| 437 | + writeAPIError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) |
| 438 | + return |
| 439 | + } |
| 440 | + update, terminal, err := normalizeStepStatusUpdate(step, body) |
| 441 | + if err != nil { |
| 442 | + writeAPIError(w, http.StatusBadRequest, err.Error()) |
| 443 | + return |
| 444 | + } |
| 445 | + updated, err := h.applyStepStatus(r.Context(), step, update, terminal) |
| 446 | + if err != nil { |
| 447 | + writeAPIError(w, http.StatusInternalServerError, "step status update failed") |
| 448 | + return |
| 449 | + } |
| 450 | + h.writeNextTokenResponse(w, r, http.StatusOK, auth, map[string]any{ |
| 451 | + "status": string(updated.Status), |
| 452 | + "conclusion": nullableConclusion(updated.Conclusion), |
| 453 | + }) |
| 454 | +} |
| 455 | + |
| 416 | 456 | type normalizedJobStatusUpdate struct { |
| 417 | 457 | Status actionsdb.WorkflowJobStatus |
| 418 | 458 | Conclusion actionsdb.NullCheckConclusion |
@@ -488,6 +528,134 @@ func validWorkflowJobTransition(from, to actionsdb.WorkflowJobStatus) bool { |
| 488 | 528 | } |
| 489 | 529 | } |
| 490 | 530 | |
| 531 | +type normalizedStepStatusUpdate struct { |
| 532 | + Status actionsdb.WorkflowStepStatus |
| 533 | + Conclusion actionsdb.NullCheckConclusion |
| 534 | + StartedAt pgtype.Timestamptz |
| 535 | + CompletedAt pgtype.Timestamptz |
| 536 | +} |
| 537 | + |
| 538 | +func normalizeStepStatusUpdate(step actionsdb.WorkflowStep, body runnerStatusRequest) (normalizedStepStatusUpdate, bool, error) { |
| 539 | + now := time.Now().UTC() |
| 540 | + status := actionsdb.WorkflowStepStatus(strings.TrimSpace(body.Status)) |
| 541 | + if status == "" { |
| 542 | + return normalizedStepStatusUpdate{}, false, errors.New("status is required") |
| 543 | + } |
| 544 | + if !validWorkflowStepTransition(step.Status, status) { |
| 545 | + return normalizedStepStatusUpdate{}, false, fmt.Errorf("invalid status transition %s -> %s", step.Status, status) |
| 546 | + } |
| 547 | + startedAt := step.StartedAt |
| 548 | + if body.StartedAt != "" { |
| 549 | + t, err := parseTimeOptional(body.StartedAt) |
| 550 | + if err != nil { |
| 551 | + return normalizedStepStatusUpdate{}, false, fmt.Errorf("started_at: %w", err) |
| 552 | + } |
| 553 | + startedAt = pgtype.Timestamptz{Time: t, Valid: !t.IsZero()} |
| 554 | + } |
| 555 | + if !startedAt.Valid && (status == actionsdb.WorkflowStepStatusRunning || |
| 556 | + status == actionsdb.WorkflowStepStatusCompleted || |
| 557 | + status == actionsdb.WorkflowStepStatusCancelled) { |
| 558 | + startedAt = pgtype.Timestamptz{Time: now, Valid: true} |
| 559 | + } |
| 560 | + completedAt := step.CompletedAt |
| 561 | + terminal := status == actionsdb.WorkflowStepStatusCompleted || |
| 562 | + status == actionsdb.WorkflowStepStatusCancelled || |
| 563 | + status == actionsdb.WorkflowStepStatusSkipped |
| 564 | + if body.CompletedAt != "" { |
| 565 | + t, err := parseTimeOptional(body.CompletedAt) |
| 566 | + if err != nil { |
| 567 | + return normalizedStepStatusUpdate{}, false, fmt.Errorf("completed_at: %w", err) |
| 568 | + } |
| 569 | + completedAt = pgtype.Timestamptz{Time: t, Valid: !t.IsZero()} |
| 570 | + } |
| 571 | + if terminal && !completedAt.Valid { |
| 572 | + completedAt = pgtype.Timestamptz{Time: now, Valid: true} |
| 573 | + } |
| 574 | + conclusion := actionsdb.NullCheckConclusion{} |
| 575 | + if terminal { |
| 576 | + c := strings.TrimSpace(body.Conclusion) |
| 577 | + if c == "" && status == actionsdb.WorkflowStepStatusCancelled { |
| 578 | + c = "cancelled" |
| 579 | + } |
| 580 | + if c == "" && status == actionsdb.WorkflowStepStatusSkipped { |
| 581 | + c = "skipped" |
| 582 | + } |
| 583 | + if !validRunnerConclusion(c) { |
| 584 | + return normalizedStepStatusUpdate{}, false, errors.New("invalid or missing conclusion") |
| 585 | + } |
| 586 | + conclusion = actionsdb.NullCheckConclusion{CheckConclusion: actionsdb.CheckConclusion(c), Valid: true} |
| 587 | + } else if strings.TrimSpace(body.Conclusion) != "" { |
| 588 | + return normalizedStepStatusUpdate{}, false, errors.New("conclusion is only valid for terminal statuses") |
| 589 | + } |
| 590 | + return normalizedStepStatusUpdate{ |
| 591 | + Status: status, |
| 592 | + Conclusion: conclusion, |
| 593 | + StartedAt: startedAt, |
| 594 | + CompletedAt: completedAt, |
| 595 | + }, terminal, nil |
| 596 | +} |
| 597 | + |
| 598 | +func validWorkflowStepTransition(from, to actionsdb.WorkflowStepStatus) bool { |
| 599 | + switch to { |
| 600 | + case actionsdb.WorkflowStepStatusRunning: |
| 601 | + return from == actionsdb.WorkflowStepStatusQueued || from == actionsdb.WorkflowStepStatusRunning |
| 602 | + case actionsdb.WorkflowStepStatusCompleted: |
| 603 | + return from == actionsdb.WorkflowStepStatusQueued || from == actionsdb.WorkflowStepStatusRunning || from == actionsdb.WorkflowStepStatusCompleted |
| 604 | + case actionsdb.WorkflowStepStatusCancelled: |
| 605 | + return from == actionsdb.WorkflowStepStatusQueued || from == actionsdb.WorkflowStepStatusRunning || from == actionsdb.WorkflowStepStatusCancelled |
| 606 | + case actionsdb.WorkflowStepStatusSkipped: |
| 607 | + return from == actionsdb.WorkflowStepStatusQueued || from == actionsdb.WorkflowStepStatusRunning || from == actionsdb.WorkflowStepStatusSkipped |
| 608 | + default: |
| 609 | + return false |
| 610 | + } |
| 611 | +} |
| 612 | + |
| 613 | +func (h *Handlers) applyStepStatus( |
| 614 | + ctx context.Context, |
| 615 | + step actionsdb.WorkflowStep, |
| 616 | + update normalizedStepStatusUpdate, |
| 617 | + terminal bool, |
| 618 | +) (actionsdb.WorkflowStep, error) { |
| 619 | + q := actionsdb.New() |
| 620 | + tx, err := h.d.Pool.Begin(ctx) |
| 621 | + if err != nil { |
| 622 | + return actionsdb.WorkflowStep{}, err |
| 623 | + } |
| 624 | + committed := false |
| 625 | + defer func() { |
| 626 | + if !committed { |
| 627 | + _ = tx.Rollback(ctx) |
| 628 | + } |
| 629 | + }() |
| 630 | + updated, err := q.UpdateWorkflowStepStatus(ctx, tx, actionsdb.UpdateWorkflowStepStatusParams{ |
| 631 | + ID: step.ID, |
| 632 | + Status: update.Status, |
| 633 | + Conclusion: update.Conclusion, |
| 634 | + StartedAt: update.StartedAt, |
| 635 | + CompletedAt: update.CompletedAt, |
| 636 | + }) |
| 637 | + if err != nil { |
| 638 | + return actionsdb.WorkflowStep{}, err |
| 639 | + } |
| 640 | + shouldNotify := false |
| 641 | + if terminal && h.d.ObjectStore != nil { |
| 642 | + if _, err := worker.Enqueue(ctx, tx, finalize.KindWorkflowFinalizeStep, finalize.Payload{StepID: step.ID}, worker.EnqueueOptions{}); err != nil { |
| 643 | + return actionsdb.WorkflowStep{}, err |
| 644 | + } |
| 645 | + shouldNotify = true |
| 646 | + } |
| 647 | + if err := tx.Commit(ctx); err != nil { |
| 648 | + return actionsdb.WorkflowStep{}, err |
| 649 | + } |
| 650 | + committed = true |
| 651 | + if shouldNotify { |
| 652 | + if err := worker.Notify(ctx, h.d.Pool); err != nil && h.d.Logger != nil { |
| 653 | + h.d.Logger.WarnContext(ctx, "runner step finalizer notify failed", "step_id", step.ID, "error", err) |
| 654 | + } |
| 655 | + } |
| 656 | + return updated, nil |
| 657 | +} |
| 658 | + |
| 491 | 659 | func (h *Handlers) applyJobStatus( |
| 492 | 660 | ctx context.Context, |
| 493 | 661 | job actionsdb.WorkflowJob, |