固定 workflow job key 身份契约
This commit is contained in:
@@ -27,8 +27,10 @@ dynamic-runner scheduler
|
|||||||
- 对 workflow 的接口保持 `[self-hosted, pod]` 和 `[self-hosted, vm]` 不变。
|
- 对 workflow 的接口保持 `[self-hosted, pod]` 和 `[self-hosted, vm]` 不变。
|
||||||
- scheduler 在没有对应 backend 容量时不领取 task,避免本地形成不可控积压。
|
- scheduler 在没有对应 backend 容量时不领取 task,避免本地形成不可控积压。
|
||||||
- 每个 executor 只执行一个 task,完成后销毁。
|
- 每个 executor 只执行一个 task,完成后销毁。
|
||||||
- SPIFFE 身份从实际领取的 task 的 repository 和 job name 派生,不需要 queued 与
|
- SPIFFE 身份从实际领取的 task 的 repository 和 workflow job key 派生,不需要 queued 与
|
||||||
in-progress webhook 的二阶段关联。
|
in-progress webhook 的二阶段关联。
|
||||||
|
- 身份中的 task 段使用 workflow job key,而不是可带空格的展示名称;job key 必须满足
|
||||||
|
`[A-Za-z_][A-Za-z0-9_-]*`。slug + hash 只保留为旧名称的显式迁移后备方案。
|
||||||
- scheduler 只做确定性的身份派生与 executor 绑定,不维护业务授权 policy;Zot、
|
- scheduler 只做确定性的身份派生与 executor 绑定,不维护业务授权 policy;Zot、
|
||||||
OpenBao 等资源服务继续是唯一授权决策点。
|
OpenBao 等资源服务继续是唯一授权决策点。
|
||||||
- scheduler 的 runner registration credential 不进入 executor;executor 只得到执行
|
- scheduler 的 runner registration credential 不进入 executor;executor 只得到执行
|
||||||
|
|||||||
@@ -7,3 +7,9 @@ require (
|
|||||||
gitea.dev/actionslib v1.0.0
|
gitea.dev/actionslib v1.0.0
|
||||||
google.golang.org/protobuf v1.36.12
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -4,5 +4,15 @@ gitea.dev/actionslib v1.0.0 h1:l0oFJP+P4Ds1rlCI5zk618dYkuBc2mU7Gz5wPeG0lZY=
|
|||||||
gitea.dev/actionslib v1.0.0/go.mod h1:6O8YHkqVTKSR0LL2e5VhIDePYzGTZCbfmSVqJWEhk9g=
|
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 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
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 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
|||||||
@@ -2,16 +2,19 @@
|
|||||||
package taskidentity
|
package taskidentity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.dev/actionslib/pkg/model"
|
||||||
runnerv1 "gitea.dev/actionslib/runner/v1"
|
runnerv1 "gitea.dev/actionslib/runner/v1"
|
||||||
)
|
)
|
||||||
|
|
||||||
var safeSegment = regexp.MustCompile(`^[A-Za-z0-9._-]+$`)
|
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.
|
// Identity is the trusted identity context extracted from a fetched task.
|
||||||
type Identity struct {
|
type Identity struct {
|
||||||
@@ -28,14 +31,14 @@ func FromTask(task *runnerv1.Task, trustDomain string) (Identity, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repository := strings.TrimSpace(task.Context.GetFields()["repository"].GetStringValue())
|
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, "/")
|
parts := strings.Split(repository, "/")
|
||||||
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
if len(parts) != 2 || parts[0] == "" || parts[1] == "" {
|
||||||
return Identity{}, fmt.Errorf("invalid task repository %q", repository)
|
return Identity{}, fmt.Errorf("invalid task repository %q", repository)
|
||||||
}
|
}
|
||||||
if taskName == "" {
|
|
||||||
return Identity{}, errors.New("task job is required")
|
|
||||||
}
|
|
||||||
trustDomain = strings.TrimSpace(trustDomain)
|
trustDomain = strings.TrimSpace(trustDomain)
|
||||||
if trustDomain == "" || strings.ContainsAny(trustDomain, "/ ") {
|
if trustDomain == "" || strings.ContainsAny(trustDomain, "/ ") {
|
||||||
return Identity{}, fmt.Errorf("invalid trust domain %q", 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{
|
path := strings.Join([]string{
|
||||||
sanitize(parts[0]),
|
sanitize(parts[0]),
|
||||||
sanitize(parts[1]),
|
sanitize(parts[1]),
|
||||||
sanitize(taskName),
|
taskName,
|
||||||
}, "/")
|
}, "/")
|
||||||
return Identity{
|
return Identity{
|
||||||
Repository: repository,
|
Repository: repository,
|
||||||
@@ -53,8 +56,27 @@ func FromTask(task *runnerv1.Task, trustDomain string) (Identity, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sanitize intentionally matches the bootstrap Python implementation so
|
func workflowTaskKey(payload []byte) (string, error) {
|
||||||
// existing Zot and OpenBao policies keep their current identity names.
|
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 {
|
func sanitize(value string) string {
|
||||||
if safeSegment.MatchString(value) {
|
if safeSegment.MatchString(value) {
|
||||||
return value
|
return value
|
||||||
|
|||||||
@@ -10,13 +10,17 @@ import (
|
|||||||
func TestFromTaskUsesFetchedContext(t *testing.T) {
|
func TestFromTaskUsesFetchedContext(t *testing.T) {
|
||||||
ctx, err := structpb.NewStruct(map[string]any{
|
ctx, err := structpb.NewStruct(map[string]any{
|
||||||
"repository": "panxiao81/gitea-dynamic-runner",
|
"repository": "panxiao81/gitea-dynamic-runner",
|
||||||
"job": "publish-images",
|
"job": "Publish images",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -29,35 +33,44 @@ func TestFromTaskUsesFetchedContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromTaskMatchesBootstrapNormalization(t *testing.T) {
|
func TestFromTaskRejectsUnsafeWorkflowJobKey(t *testing.T) {
|
||||||
ctx, err := structpb.NewStruct(map[string]any{
|
ctx, err := structpb.NewStruct(map[string]any{"repository": "owner/repo"})
|
||||||
"repository": "panxiao81/postgresql-tenant-operator",
|
|
||||||
"job": "Run on Ubuntu",
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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")
|
func TestBackoffTaskSegmentIsStableAndCollisionResistant(t *testing.T) {
|
||||||
if err != nil {
|
got := BackoffTaskSegment("Run on Ubuntu")
|
||||||
t.Fatal(err)
|
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 == BackoffTaskSegment("Run@on Ubuntu") {
|
||||||
if got.SPIFFEID != want {
|
t.Fatal("different legacy names must not collide after slugging")
|
||||||
t.Fatalf("SPIFFE ID = %q, want %q", got.SPIFFEID, want)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFromTaskRejectsIncompleteServerContext(t *testing.T) {
|
func TestFromTaskRejectsIncompleteServerContext(t *testing.T) {
|
||||||
for _, fields := range []map[string]any{
|
for _, fields := range []map[string]any{
|
||||||
{"repository": "invalid", "job": "test"},
|
{"repository": "invalid"},
|
||||||
{"repository": "owner/repo", "job": ""},
|
{"repository": ""},
|
||||||
} {
|
} {
|
||||||
ctx, err := structpb.NewStruct(fields)
|
ctx, err := structpb.NewStruct(fields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Fatalf("expected invalid task context to fail: %#v", fields)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ func TestRunDerivesIdentityBeforeDispatch(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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{}
|
dispatcher := &recordingDispatcher{}
|
||||||
scheduler := Scheduler{TrustDomain: "ddupan.top", Dispatcher: dispatcher}
|
scheduler := Scheduler{TrustDomain: "ddupan.top", Dispatcher: dispatcher}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user