All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
CI / Woodpecker: - Add explicit depends_on to all .woodpecker.yml steps (rdev + templates) - Fix skip_tls_verify -> skip-tls-verify (correct Kaniko flag name) - Add replicasets get/list to deployer RBAC for rollout status - Skeleton template: add failure:ignore on docs steps, Traefik TLS annotations on ingress, depends_on on verify step Component templates: - Fix container name in deploy steps (PROJECT_NAME-COMPONENT_NAME) - Replace kubectl scale with kubectl patch for replicas - Add post-deploy image verification and rollout status checks - Applied consistently across all 5 component templates Adapters: - gitea: Add HTTP client timeout (30s), context cancellation checks, handle 404 on GetRepo/DeleteRepo - zot: Add retry with exponential backoff (doWithRetry), limit response body reads to 10MB - cockroach: Use net.JoinHostPort for IPv6-safe DSN construction - woodpecker: Fix error wrapping (%v -> %w) - redis: Fix error wrapping (%v -> %w) - deployer: Add context cancellation checks Services: - apikey_service: Fix error wrapping (%v -> %w) - component_deploy: Fix error wrapping (%v -> %w) - project_infra: Fix error wrapping (%v -> %w) - webhook/dispatcher: Fix error wrapping (%v -> %w) Other: - CLAUDE.md: Add guide links for Gitea, Go 1.25, Woodpecker v3, Traefik v3, Zot registry - circuitbreaker: Add test for error wrapping - docs: Update deployment, troubleshooting, and runbook docs - health: Fix error wrapping (%v -> %w) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
268 lines
6.7 KiB
Go
268 lines
6.7 KiB
Go
// Pipeline-related methods for the Woodpecker CI adapter.
|
|
package woodpecker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"go.woodpecker-ci.org/woodpecker/v3/woodpecker-go/woodpecker"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
)
|
|
|
|
// ListPipelines returns recent CI pipeline executions for a repository.
|
|
func (c *Client) ListPipelines(ctx context.Context, owner, repo string) ([]*domain.CIPipeline, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
fullName := owner + "/" + repo
|
|
|
|
r, err := c.client.RepoLookup(fullName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("repo not found %s: %w", fullName, err)
|
|
}
|
|
|
|
pipelines, err := c.client.PipelineList(r.ID, woodpecker.PipelineListOptions{})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list pipelines: %w", err)
|
|
}
|
|
|
|
result := make([]*domain.CIPipeline, len(pipelines))
|
|
for i, p := range pipelines {
|
|
result[i] = pipelineFromWoodpecker(p)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// GetPipeline returns a specific pipeline execution by number.
|
|
func (c *Client) GetPipeline(ctx context.Context, owner, repo string, number int64) (*domain.CIPipeline, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
fullName := owner + "/" + repo
|
|
|
|
r, err := c.client.RepoLookup(fullName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("repo not found %s: %w", fullName, err)
|
|
}
|
|
|
|
p, err := c.client.Pipeline(r.ID, number)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pipeline %d not found: %w", number, err)
|
|
}
|
|
|
|
return pipelineFromWoodpecker(p), nil
|
|
}
|
|
|
|
// GetPipelineSteps returns detailed step information for a pipeline.
|
|
// For failed steps, includes the last 50 lines of log output.
|
|
func (c *Client) GetPipelineSteps(ctx context.Context, owner, repo string, number int64) (*domain.CIPipelineSteps, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
fullName := owner + "/" + repo
|
|
|
|
r, err := c.client.RepoLookup(fullName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("repo not found %s: %w", fullName, err)
|
|
}
|
|
|
|
p, err := c.client.Pipeline(r.ID, number)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("pipeline %d not found: %w", number, err)
|
|
}
|
|
|
|
result := &domain.CIPipelineSteps{
|
|
PipelineNumber: number,
|
|
URL: fmt.Sprintf("%s/%s/%d", c.url, fullName, number),
|
|
Steps: make([]domain.CIPipelineStep, 0),
|
|
}
|
|
|
|
// Flatten workflows -> steps
|
|
for _, wf := range p.Workflows {
|
|
if wf == nil {
|
|
continue
|
|
}
|
|
for _, step := range wf.Children {
|
|
if step == nil {
|
|
continue
|
|
}
|
|
|
|
// Calculate duration
|
|
var duration int
|
|
if step.Stopped > 0 && step.Started > 0 {
|
|
duration = int(step.Stopped - step.Started)
|
|
}
|
|
|
|
// Convert exit code
|
|
var exitCode *int
|
|
if step.State == "success" || step.State == "failure" || step.State == "killed" {
|
|
code := step.ExitCode
|
|
exitCode = &code
|
|
}
|
|
|
|
domainStep := domain.CIPipelineStep{
|
|
ID: step.ID,
|
|
Name: step.Name,
|
|
Status: step.State,
|
|
ExitCode: exitCode,
|
|
Duration: duration,
|
|
Error: step.Error,
|
|
}
|
|
|
|
// Fetch logs for failed steps
|
|
if step.State == "failure" || step.State == "error" || step.State == "killed" {
|
|
logs, err := c.client.StepLogEntries(r.ID, number, step.ID)
|
|
if err != nil {
|
|
c.logger.Warn("failed to fetch step logs", "step", step.Name, "error", err)
|
|
} else {
|
|
domainStep.Log = formatLogEntries(logs, 50)
|
|
}
|
|
}
|
|
|
|
result.Steps = append(result.Steps, domainStep)
|
|
}
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// TriggerBuild manually starts a new pipeline build on the specified branch.
|
|
func (c *Client) TriggerBuild(ctx context.Context, owner, repo, branch string) (int64, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return 0, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
fullName := owner + "/" + repo
|
|
|
|
r, err := c.client.RepoLookup(fullName)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("repo not found %s: %w", fullName, err)
|
|
}
|
|
|
|
// Create a new pipeline for the branch (with circuit breaker protection)
|
|
var pipeline *woodpecker.Pipeline
|
|
err = c.executeWithCircuitBreaker(func() error {
|
|
var createErr error
|
|
pipeline, createErr = c.client.PipelineCreate(r.ID, &woodpecker.PipelineOptions{
|
|
Branch: branch,
|
|
})
|
|
return createErr
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("failed to trigger build: %w", err)
|
|
}
|
|
|
|
c.logger.Info("build triggered", "repo", fullName, "branch", branch, "pipeline", pipeline.Number)
|
|
return pipeline.Number, nil
|
|
}
|
|
|
|
// RetryPipeline restarts a failed or stopped pipeline.
|
|
func (c *Client) RetryPipeline(ctx context.Context, owner, repo string, number int64) (*domain.CIPipeline, error) {
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
default:
|
|
}
|
|
|
|
fullName := owner + "/" + repo
|
|
|
|
r, err := c.client.RepoLookup(fullName)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("repo not found %s: %w", fullName, err)
|
|
}
|
|
|
|
// Restart the pipeline using PipelineStart (with circuit breaker protection)
|
|
var pipeline *woodpecker.Pipeline
|
|
err = c.executeWithCircuitBreaker(func() error {
|
|
var startErr error
|
|
pipeline, startErr = c.client.PipelineStart(r.ID, number, woodpecker.PipelineStartOptions{})
|
|
return startErr
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to retry pipeline %d: %w", number, err)
|
|
}
|
|
|
|
c.logger.Info("pipeline retried", "repo", fullName, "pipeline", number, "new_status", pipeline.Status)
|
|
return pipelineFromWoodpecker(pipeline), nil
|
|
}
|
|
|
|
// pipelineFromWoodpecker converts a woodpecker.Pipeline to domain.CIPipeline.
|
|
func pipelineFromWoodpecker(p *woodpecker.Pipeline) *domain.CIPipeline {
|
|
var started, finished time.Time
|
|
if p.Started > 0 {
|
|
started = time.Unix(p.Started, 0)
|
|
}
|
|
if p.Finished > 0 {
|
|
finished = time.Unix(p.Finished, 0)
|
|
}
|
|
|
|
// Map pipeline errors
|
|
var errors []domain.CIPipelineError
|
|
for _, e := range p.Errors {
|
|
if e != nil {
|
|
errors = append(errors, domain.CIPipelineError{
|
|
Type: e.Type,
|
|
Message: e.Message,
|
|
IsWarning: e.IsWarning,
|
|
})
|
|
}
|
|
}
|
|
|
|
return &domain.CIPipeline{
|
|
ID: p.ID,
|
|
Number: p.Number,
|
|
Status: p.Status,
|
|
Event: p.Event,
|
|
Branch: p.Branch,
|
|
Commit: p.Commit,
|
|
Message: p.Message,
|
|
Author: p.Author,
|
|
Started: started,
|
|
Finished: finished,
|
|
Errors: errors,
|
|
}
|
|
}
|
|
|
|
// formatLogEntries combines log entries and returns the last N lines.
|
|
func formatLogEntries(entries []*woodpecker.LogEntry, maxLines int) string {
|
|
if len(entries) == 0 {
|
|
return ""
|
|
}
|
|
|
|
// Collect all lines
|
|
var lines []string
|
|
for _, entry := range entries {
|
|
if entry != nil && len(entry.Data) > 0 {
|
|
// Data is []byte, convert to string and split
|
|
data := string(entry.Data)
|
|
entryLines := strings.Split(data, "\n")
|
|
for _, line := range entryLines {
|
|
if line != "" {
|
|
lines = append(lines, line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Return last maxLines
|
|
if len(lines) > maxLines {
|
|
lines = lines[len(lines)-maxLines:]
|
|
}
|
|
|
|
return strings.Join(lines, "\n")
|
|
}
|