diff --git a/docs/runner-protocol-roadmap.md b/docs/runner-protocol-roadmap.md index a185c79..80d98f3 100644 --- a/docs/runner-protocol-roadmap.md +++ b/docs/runner-protocol-roadmap.md @@ -27,8 +27,10 @@ dynamic-runner scheduler - 对 workflow 的接口保持 `[self-hosted, pod]` 和 `[self-hosted, vm]` 不变。 - scheduler 在没有对应 backend 容量时不领取 task,避免本地形成不可控积压。 - 每个 executor 只执行一个 task,完成后销毁。 -- SPIFFE 身份从实际领取的 task 的 repository 和 job name 派生,不需要 queued 与 +- SPIFFE 身份从实际领取的 task 的 repository 和 workflow job key 派生,不需要 queued 与 in-progress webhook 的二阶段关联。 +- 身份中的 task 段使用 workflow job key,而不是可带空格的展示名称;job key 必须满足 + `[A-Za-z_][A-Za-z0-9_-]*`。slug + hash 只保留为旧名称的显式迁移后备方案。 - scheduler 只做确定性的身份派生与 executor 绑定,不维护业务授权 policy;Zot、 OpenBao 等资源服务继续是唯一授权决策点。 - scheduler 的 runner registration credential 不进入 executor;executor 只得到执行 diff --git a/go.mod b/go.mod index 065521b..406657f 100644 --- a/go.mod +++ b/go.mod @@ -7,3 +7,9 @@ require ( gitea.dev/actionslib v1.0.0 google.golang.org/protobuf v1.36.12 ) + +require ( + github.com/sirupsen/logrus v1.10.2 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.6 // indirect + golang.org/x/sys v0.46.0 // indirect +) diff --git a/go.sum b/go.sum index f6c544f..f0c99dc 100644 --- a/go.sum +++ b/go.sum @@ -4,5 +4,15 @@ gitea.dev/actionslib v1.0.0 h1:l0oFJP+P4Ds1rlCI5zk618dYkuBc2mU7Gz5wPeG0lZY= gitea.dev/actionslib v1.0.0/go.mod h1:6O8YHkqVTKSR0LL2e5VhIDePYzGTZCbfmSVqJWEhk9g= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo= +github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4= +go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/internal/taskidentity/identity.go b/internal/taskidentity/identity.go index 747633f..3e991ac 100644 --- a/internal/taskidentity/identity.go +++ b/internal/taskidentity/identity.go @@ -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 diff --git a/internal/taskidentity/identity_test.go b/internal/taskidentity/identity_test.go index c15c226..54801b4 100644 --- a/internal/taskidentity/identity_test.go +++ b/internal/taskidentity/identity_test.go @@ -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) } } diff --git a/internal/taskscheduler/scheduler_test.go b/internal/taskscheduler/scheduler_test.go index 6dd1f62..1e394f9 100644 --- a/internal/taskscheduler/scheduler_test.go +++ b/internal/taskscheduler/scheduler_test.go @@ -25,7 +25,11 @@ func TestRunDerivesIdentityBeforeDispatch(t *testing.T) { if err != nil { t.Fatal(err) } - task := &runnerv1.Task{Id: 42, Context: taskContext} + task := &runnerv1.Task{ + Id: 42, + Context: taskContext, + WorkflowPayload: []byte("jobs:\n test:\n runs-on: [self-hosted, pod]\n steps: []\n"), + } dispatcher := &recordingDispatcher{} scheduler := Scheduler{TrustDomain: "ddupan.top", Dispatcher: dispatcher}