Files
gitea-dynamic-runner/internal/taskidentity/identity.go
panxiao81 5e94182308
test / python (pull_request) Successful in 9s
test / shell (pull_request) Successful in 15s
test / go (pull_request) Successful in 2m18s
fix: 从后端恢复 Runner claim
2026-09-21 06:18:23 +00:00

109 lines
3.4 KiB
Go

// Package taskidentity derives workload identities from tasks assigned by Gitea.
package taskidentity
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"regexp"
"strings"
"gitea.dev/actionslib/pkg/model"
runnerv1 "gitea.dev/actionslib/runner/v1"
)
var safeSegment = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
var safeTaskKey = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_-]*$`)
// Identity is the trusted identity context extracted from a fetched task.
type Identity struct {
Repository string
Task string
SPIFFEID string
}
// FromMetadata validates identity fields recovered from backend-owned state.
func FromMetadata(repository, task, spiffeID, trustDomain string) (Identity, error) {
parts := strings.Split(repository, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" || !safeTaskKey.MatchString(task) {
return Identity{}, errors.New("invalid recovered repository or task identity")
}
expected := "spiffe://" + trustDomain + "/ci/" + strings.Join([]string{
sanitize(parts[0]), sanitize(parts[1]), task,
}, "/")
if spiffeID != expected {
return Identity{}, fmt.Errorf("recovered SPIFFE ID %q does not match %q", spiffeID, expected)
}
return Identity{Repository: repository, Task: task, SPIFFEID: spiffeID}, nil
}
// FromTask derives the repository/task SPIFFE ID from Gitea's trusted task
// context. Workflow input never supplies or overrides the resulting ID.
func FromTask(task *runnerv1.Task, trustDomain string) (Identity, error) {
if task == nil || task.Context == nil {
return Identity{}, errors.New("task context is required")
}
repository := strings.TrimSpace(task.Context.GetFields()["repository"].GetStringValue())
taskName, err := workflowTaskKey(task.WorkflowPayload)
if err != nil {
return Identity{}, err
}
parts := strings.Split(repository, "/")
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
return Identity{}, fmt.Errorf("invalid task repository %q", repository)
}
trustDomain = strings.TrimSpace(trustDomain)
if trustDomain == "" || strings.ContainsAny(trustDomain, "/ ") {
return Identity{}, fmt.Errorf("invalid trust domain %q", trustDomain)
}
path := strings.Join([]string{
sanitize(parts[0]),
sanitize(parts[1]),
taskName,
}, "/")
return Identity{
Repository: repository,
Task: taskName,
SPIFFEID: "spiffe://" + trustDomain + "/ci/" + path,
}, nil
}
func workflowTaskKey(payload []byte) (string, error) {
workflow, err := model.ReadWorkflow(bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("parse task workflow: %w", err)
}
jobIDs := workflow.GetJobIDs()
if len(jobIDs) != 1 {
return "", fmt.Errorf("task workflow must contain exactly one job, got %d", len(jobIDs))
}
if !safeTaskKey.MatchString(jobIDs[0]) {
return "", fmt.Errorf("task job key %q must match %s", jobIDs[0], safeTaskKey)
}
return jobIDs[0], nil
}
// BackoffTaskSegment deterministically converts a legacy display name into a
// collision-resistant path segment. Canonical task identities do not use it.
func BackoffTaskSegment(value string) string {
return sanitize(value)
}
func sanitize(value string) string {
if safeSegment.MatchString(value) {
return value
}
slug := strings.Trim(regexp.MustCompile(`[^A-Za-z0-9._-]+`).ReplaceAllString(value, "-"), "-._")
if len(slug) > 48 {
slug = strings.TrimRight(slug[:48], "-._")
}
if slug == "" {
slug = "segment"
}
digest := fmt.Sprintf("%x", sha256.Sum256([]byte(value)))[:12]
return slug + "-" + digest
}