固定 workflow job key 身份契约
This commit is contained in:
@@ -2,16 +2,19 @@
|
||||
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 {
|
||||
@@ -28,14 +31,14 @@ func FromTask(task *runnerv1.Task, trustDomain string) (Identity, error) {
|
||||
}
|
||||
|
||||
repository := strings.TrimSpace(task.Context.GetFields()["repository"].GetStringValue())
|
||||
taskName := strings.TrimSpace(task.Context.GetFields()["job"].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)
|
||||
}
|
||||
if taskName == "" {
|
||||
return Identity{}, errors.New("task job is required")
|
||||
}
|
||||
trustDomain = strings.TrimSpace(trustDomain)
|
||||
if trustDomain == "" || strings.ContainsAny(trustDomain, "/ ") {
|
||||
return Identity{}, fmt.Errorf("invalid trust domain %q", trustDomain)
|
||||
@@ -44,7 +47,7 @@ func FromTask(task *runnerv1.Task, trustDomain string) (Identity, error) {
|
||||
path := strings.Join([]string{
|
||||
sanitize(parts[0]),
|
||||
sanitize(parts[1]),
|
||||
sanitize(taskName),
|
||||
taskName,
|
||||
}, "/")
|
||||
return Identity{
|
||||
Repository: repository,
|
||||
@@ -53,8 +56,27 @@ func FromTask(task *runnerv1.Task, trustDomain string) (Identity, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sanitize intentionally matches the bootstrap Python implementation so
|
||||
// existing Zot and OpenBao policies keep their current identity names.
|
||||
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
|
||||
|
||||
@@ -10,13 +10,17 @@ import (
|
||||
func TestFromTaskUsesFetchedContext(t *testing.T) {
|
||||
ctx, err := structpb.NewStruct(map[string]any{
|
||||
"repository": "panxiao81/gitea-dynamic-runner",
|
||||
"job": "publish-images",
|
||||
"job": "Publish images",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := FromTask(&runnerv1.Task{Id: 900, Context: ctx}, "ddupan.top")
|
||||
got, err := FromTask(&runnerv1.Task{
|
||||
Id: 900,
|
||||
Context: ctx,
|
||||
WorkflowPayload: []byte("jobs:\n publish-images:\n runs-on: [self-hosted, vm]\n steps: []\n"),
|
||||
}, "ddupan.top")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -29,35 +33,44 @@ func TestFromTaskUsesFetchedContext(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromTaskMatchesBootstrapNormalization(t *testing.T) {
|
||||
ctx, err := structpb.NewStruct(map[string]any{
|
||||
"repository": "panxiao81/postgresql-tenant-operator",
|
||||
"job": "Run on Ubuntu",
|
||||
})
|
||||
func TestFromTaskRejectsUnsafeWorkflowJobKey(t *testing.T) {
|
||||
ctx, err := structpb.NewStruct(map[string]any{"repository": "owner/repo"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
task := &runnerv1.Task{
|
||||
Context: ctx,
|
||||
WorkflowPayload: []byte("jobs:\n 'Run on Ubuntu':\n runs-on: self-hosted\n steps: []\n"),
|
||||
}
|
||||
if _, err := FromTask(task, "ddupan.top"); err == nil {
|
||||
t.Fatal("expected unsafe job key to fail")
|
||||
}
|
||||
}
|
||||
|
||||
got, err := FromTask(&runnerv1.Task{Context: ctx}, "ddupan.top")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
func TestBackoffTaskSegmentIsStableAndCollisionResistant(t *testing.T) {
|
||||
got := BackoffTaskSegment("Run on Ubuntu")
|
||||
if got != "Run-on-Ubuntu-8b7cd4c244fb" {
|
||||
t.Fatalf("backoff segment = %q", got)
|
||||
}
|
||||
want := "spiffe://ddupan.top/ci/panxiao81/postgresql-tenant-operator/Run-on-Ubuntu-8b7cd4c244fb"
|
||||
if got.SPIFFEID != want {
|
||||
t.Fatalf("SPIFFE ID = %q, want %q", got.SPIFFEID, want)
|
||||
if got == BackoffTaskSegment("Run@on Ubuntu") {
|
||||
t.Fatal("different legacy names must not collide after slugging")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromTaskRejectsIncompleteServerContext(t *testing.T) {
|
||||
for _, fields := range []map[string]any{
|
||||
{"repository": "invalid", "job": "test"},
|
||||
{"repository": "owner/repo", "job": ""},
|
||||
{"repository": "invalid"},
|
||||
{"repository": ""},
|
||||
} {
|
||||
ctx, err := structpb.NewStruct(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := FromTask(&runnerv1.Task{Context: ctx}, "ddupan.top"); err == nil {
|
||||
task := &runnerv1.Task{
|
||||
Context: ctx,
|
||||
WorkflowPayload: []byte("jobs:\n test:\n runs-on: self-hosted\n steps: []\n"),
|
||||
}
|
||||
if _, err := FromTask(task, "ddupan.top"); err == nil {
|
||||
t.Fatalf("expected invalid task context to fail: %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user