feat: publish immutable workflow definitions with typed SDK
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
||||
type BootstrapInput struct {
|
||||
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
|
||||
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
||||
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
||||
Application string // Application 新应用名称,与 ExistingApplicationID 二选一。
|
||||
@@ -76,6 +77,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
||||
if in.WorkerAccess {
|
||||
permissions.Actions = append(permissions.Actions, "workers:connect", "workers:consume")
|
||||
}
|
||||
if in.WorkflowAccess {
|
||||
permissions.Actions = append(permissions.Actions, "workflows:definitions:read", "workflows:definitions:publish")
|
||||
}
|
||||
if in.ScheduleAccess {
|
||||
permissions.Actions = append(permissions.Actions, "schedules:read", "schedules:manage")
|
||||
}
|
||||
|
||||
258
internal/storage/mysql/workflow_definitions.go
Normal file
258
internal/storage/mysql/workflow_definitions.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
driver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// WorkflowDefinitionStore 只管理定义与不可变版本,关联校验、幂等和审计同事务,不创建业务任务。
|
||||
type WorkflowDefinitionStore struct{ *TaskStore }
|
||||
|
||||
func NewWorkflowDefinitionStore(db *sql.DB) *WorkflowDefinitionStore {
|
||||
return &WorkflowDefinitionStore{NewTaskStore(db)}
|
||||
}
|
||||
|
||||
var _ definition.Store = (*WorkflowDefinitionStore)(nil)
|
||||
|
||||
func readWorkflowDefinition(ctx context.Context, tx *sql.Tx, ns, id core.ID, lock bool) (definition.Summary, error) {
|
||||
var out definition.Summary
|
||||
q := `SELECT id,namespace_id,name,description,revision,created_at,updated_at FROM weave_definitions WHERE namespace_id=? AND id=?`
|
||||
if lock {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
err := tx.QueryRowContext(ctx, q, ns, id).Scan(&out.ID, &out.NamespaceID, &out.Name, &out.Description, &out.Revision, &out.CreatedAt, &out.UpdatedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = fault.NotFound
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
func workflowAnalysis(c definition.Checked) definition.Analysis {
|
||||
queues := append([]core.ID{}, c.Queues...)
|
||||
return definition.Analysis{Steps: c.Steps, MaxConcurrency: c.MaxConcurrency, MaxTaskAttempts: c.MaxTaskAttempts, Queues: queues}
|
||||
}
|
||||
func readWorkflowVersion(ctx context.Context, tx *sql.Tx, ns, id core.ID, version string) (definition.VersionView, error) {
|
||||
var out definition.VersionView
|
||||
var raw []byte
|
||||
var schema uint32
|
||||
var actor string
|
||||
var digest core.Digest
|
||||
err := tx.QueryRowContext(ctx, `SELECT id,namespace_id,definition_id,version,schema_version,specification,content_digest,published_by,created_at FROM weave_definition_versions WHERE namespace_id=? AND definition_id=? AND version=?`, ns, id, version).Scan(&out.ID, &out.NamespaceID, &out.DefinitionID, &out.Version, &schema, &raw, &digest, &actor, &out.PublishedAt)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return out, fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.PublishedBy = core.ID(actor)
|
||||
if out.PublishedBy.Validate() != nil {
|
||||
return out, errors.New("invalid stored workflow publisher")
|
||||
}
|
||||
if len(raw) > definition.MaxSpecificationBytes || json.Unmarshal(raw, &out.Specification) != nil || out.Specification.SchemaVersion != schema {
|
||||
return out, errors.New("invalid stored workflow specification")
|
||||
}
|
||||
checked, err := definition.Check(out.Specification)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
// 同时检查原文,不能让JSON解码忽略的未知字段或大小写别名绕过完整性验证。
|
||||
canonical, err := jsonvalue.Canonical(raw)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
rawDigest := core.Digest(sha256.Sum256(append([]byte("orbit.weave.spec.v1\x00"), canonical...)))
|
||||
if digest != checked.Digest || digest != rawDigest {
|
||||
return out, errors.New("workflow version digest mismatch")
|
||||
}
|
||||
out.ContentDigest = hex.EncodeToString(digest[:])
|
||||
out.Analysis = workflowAnalysis(checked)
|
||||
return out, nil
|
||||
}
|
||||
func authorizeWorkflowQueues(ctx context.Context, tx *sql.Tx, ns core.ID, p auth.Permissions, queues []core.ID) error {
|
||||
for _, id := range queues {
|
||||
if !p.AllowsQueue(id) {
|
||||
return fault.NotFound
|
||||
}
|
||||
var found core.ID
|
||||
err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, ns, id).Scan(&found)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fault.NotFound
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (s *WorkflowDefinitionStore) Create(ctx context.Context, t auth.Token, key string, in definition.CreateInput) (definition.CreateResult, error) {
|
||||
var out definition.CreateResult
|
||||
err := s.transaction(ctx, t, definition.PublishAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "workflow.definition.create", "", key, "workflow_definition", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_definitions WHERE namespace_id=?`, t.NamespaceID).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= definition.MaxDefinitions {
|
||||
return fault.New("WORKFLOW_DEFINITION_LIMIT", "namespace workflow definition limit reached")
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO weave_definitions(namespace_id,id,name,description,created_at,updated_at) VALUES (?,?,?,?,?,?)`, t.NamespaceID, id, in.Name, in.Description, now, now)
|
||||
var duplicate *driver.MySQLError
|
||||
if errors.As(err, &duplicate) && duplicate.Number == 1062 {
|
||||
return fault.Conflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Definition, err = readWorkflowDefinition(ctx, tx, t.NamespaceID, id, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := appendAudit(ctx, tx, t, identity, id, "workflow_definition", "workflow.definition.create", "create workflow definition", nil, out.Definition); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *WorkflowDefinitionStore) Get(ctx context.Context, t auth.Token, id core.ID) (definition.Summary, error) {
|
||||
var out definition.Summary
|
||||
err := s.transaction(ctx, t, definition.ReadAction, func(tx *sql.Tx, _ auth.Permissions) error {
|
||||
var err error
|
||||
out, err = readWorkflowDefinition(ctx, tx, t.NamespaceID, id, false)
|
||||
return err
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *WorkflowDefinitionStore) Publish(ctx context.Context, t auth.Token, id core.ID, key string, in definition.PublishInput) (definition.PublishResult, error) {
|
||||
var out definition.PublishResult
|
||||
checked, err := definition.Check(in.Specification)
|
||||
if err != nil {
|
||||
return out, fault.Invalid(err.Error())
|
||||
}
|
||||
err = s.transaction(ctx, t, definition.PublishAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil {
|
||||
return err
|
||||
}
|
||||
// 摘要身份冻结整个命令(含原因),同版本去重只比较specification内容摘要。
|
||||
identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "workflow.version.publish", string(id), key, "workflow_definition", id, in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before, err := readWorkflowDefinition(ctx, tx, t.NamespaceID, id, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, checked.Queues); err != nil {
|
||||
return err
|
||||
}
|
||||
stored, err := readWorkflowVersion(ctx, tx, t.NamespaceID, id, in.Version)
|
||||
if err != nil && !errors.Is(err, fault.NotFound) {
|
||||
return err
|
||||
}
|
||||
if cached != nil {
|
||||
if err != nil {
|
||||
return errors.New("published workflow version missing")
|
||||
}
|
||||
if err := json.Unmarshal(cached, &out); err != nil {
|
||||
return err
|
||||
}
|
||||
if out.Version.ID != stored.ID || stored.ContentDigest != hex.EncodeToString(checked.Digest[:]) {
|
||||
return errors.New("workflow publication receipt mismatch")
|
||||
}
|
||||
out.Version = stored
|
||||
out.Deduplicated = true
|
||||
return nil
|
||||
}
|
||||
if err == nil {
|
||||
if stored.ContentDigest != hex.EncodeToString(checked.Digest[:]) {
|
||||
return fault.Conflict
|
||||
}
|
||||
out = definition.PublishResult{Version: stored, Deduplicated: true}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
}
|
||||
var count int
|
||||
if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_definition_versions WHERE namespace_id=? AND definition_id=?`, t.NamespaceID, id).Scan(&count); err != nil {
|
||||
return err
|
||||
}
|
||||
if count >= definition.MaxVersions {
|
||||
return fault.New("WORKFLOW_VERSION_LIMIT", "workflow definition version limit reached")
|
||||
}
|
||||
versionID, err := core.NewID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
raw, err := jsonwire.Marshal(in.Specification)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now, err := dbNow(ctx, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO weave_definition_versions(namespace_id,id,definition_id,version,schema_version,specification,content_digest,published_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)`, t.NamespaceID, versionID, id, in.Version, in.Specification.SchemaVersion, string(raw), checked.Digest, string(t.CredentialID), now, now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE weave_definitions SET revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, now, t.NamespaceID, id); err != nil {
|
||||
return err
|
||||
}
|
||||
out.Version, err = readWorkflowVersion(ctx, tx, t.NamespaceID, id, in.Version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 审计记录固定版本、摘要和预算,不复制潜在敏感的业务常量。
|
||||
audit := map[string]any{"version_id": versionID, "version": in.Version, "content_digest": out.Version.ContentDigest, "analysis": out.Version.Analysis, "revision": before.Revision + 1}
|
||||
if err := appendAudit(ctx, tx, t, identity, id, "workflow_definition", "workflow.version.publish", in.Reason, map[string]any{"revision": before.Revision}, audit); err != nil {
|
||||
return err
|
||||
}
|
||||
return saveIdentity(ctx, tx, t.NamespaceID, identity, out)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
func (s *WorkflowDefinitionStore) Version(ctx context.Context, t auth.Token, id core.ID, version string) (definition.VersionView, error) {
|
||||
var out definition.VersionView
|
||||
err := s.transaction(ctx, t, definition.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
|
||||
if _, err := readWorkflowDefinition(ctx, tx, t.NamespaceID, id, false); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
out, err = readWorkflowVersion(ctx, tx, t.NamespaceID, id, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, out.Analysis.Queues)
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
295
internal/storage/mysql/workflow_definitions_integration_test.go
Normal file
295
internal/storage/mysql/workflow_definitions_integration_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
)
|
||||
|
||||
type workflowFixture struct {
|
||||
t *testing.T
|
||||
ctx context.Context
|
||||
db *sql.DB
|
||||
resource BootstrapResult
|
||||
token auth.Token
|
||||
api *definition.Service
|
||||
}
|
||||
|
||||
func newWorkflowFixture(t *testing.T, ctx context.Context, db *sql.DB, access bool) *workflowFixture {
|
||||
t.Helper()
|
||||
f := &workflowFixture{t: t, ctx: ctx, db: db, api: definition.New(NewWorkflowDefinitionStore(db))}
|
||||
if err := Bootstrap(ctx, db, BootstrapInput{Application: "workflow-" + string(executionID(t)), Namespace: "test", Environment: "test", Queue: "work", WorkflowAccess: access}, func(v BootstrapResult) error { f.resource = v; return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var err error
|
||||
f.token, err = auth.Parse(f.resource.Token)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return f
|
||||
}
|
||||
func (f *workflowFixture) create(key string) definition.CreateResult {
|
||||
f.t.Helper()
|
||||
v, err := f.api.Create(f.ctx, f.token, key, definition.CreateInput{Name: "flow-" + key, Description: "通用作业处理"})
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
func (f *workflowFixture) count(q string, args ...any) int {
|
||||
f.t.Helper()
|
||||
var n int
|
||||
if err := f.db.QueryRowContext(f.ctx, q, args...).Scan(&n); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return n
|
||||
}
|
||||
func (f *workflowFixture) exec(q string, args ...any) {
|
||||
f.t.Helper()
|
||||
if _, err := f.db.ExecContext(f.ctx, q, args...); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
func workflowTestSpec(queue core.ID) definition.Specification {
|
||||
typ := definition.Type{Kind: "OBJECT", Fields: map[string]definition.Type{"n": {Kind: "NUMBER"}}}
|
||||
task := definition.Task{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: definition.Value{Kind: "LITERAL", Literal: json.RawMessage(`{"n":9007199254740993}`)}, ResultType: typ, TimeoutMS: 5000, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 3000}, RecoverySafety: pulse.RecoveryReconcileOnly}
|
||||
return definition.Specification{SchemaVersion: 1, InputType: typ, TimeoutMS: 30000, FailurePolicy: "REVIEW", Body: definition.Block{Steps: []definition.Step{{Key: "prepare", Kind: "TASK", Task: &task}}, OutputType: typ, Output: definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: "prepare"}}}}
|
||||
}
|
||||
func TestIntegrationWorkflowDefinitions(t *testing.T) {
|
||||
dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("requires disposable MySQL")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
db, err := Open(ctx, dsn, 24, 8)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
if err := Migrate(ctx, db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Run("concurrency_precision_and_immutable_versions", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
key := string(executionID(t))
|
||||
created := f.create(key)
|
||||
id := created.Definition.ID
|
||||
dupe, err := f.api.Create(ctx, f.token, key, definition.CreateInput{Name: "flow-" + key, Description: "通用作业处理"})
|
||||
if err != nil || !dupe.Deduplicated || dupe.Definition.ID != id {
|
||||
t.Fatal("create replay", err)
|
||||
}
|
||||
in := definition.PublishInput{Version: "v1", Specification: workflowTestSpec(f.resource.QueueID), Reason: "release fixed specification"}
|
||||
type result struct {
|
||||
out definition.PublishResult
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 12)
|
||||
var wg sync.WaitGroup
|
||||
// 原键竞争和不同键的同版本竞争都必须只提交一次发布。
|
||||
for i := 0; i < 12; i++ {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
k := fmt.Sprintf("release-%d", i/2)
|
||||
out, err := f.api.Publish(ctx, f.token, id, k, in)
|
||||
ch <- result{out, err}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
close(ch)
|
||||
fresh := 0
|
||||
var original definition.VersionView
|
||||
for r := range ch {
|
||||
if r.err != nil {
|
||||
t.Fatal(r.err)
|
||||
}
|
||||
if original.ID == "" {
|
||||
original = r.out.Version
|
||||
}
|
||||
if !reflect.DeepEqual(original, r.out.Version) {
|
||||
t.Fatal("version changed across replies")
|
||||
}
|
||||
if !r.out.Deduplicated {
|
||||
fresh++
|
||||
}
|
||||
}
|
||||
if fresh != 1 {
|
||||
t.Fatalf("new publications=%d", fresh)
|
||||
}
|
||||
in.Specification.Body.Steps[0].Task.Input.Literal = json.RawMessage(`{"n":9007199254740993.0}`)
|
||||
equal, err := f.api.Publish(ctx, f.token, id, "release-0", in)
|
||||
if err != nil || !equal.Deduplicated || !reflect.DeepEqual(equal.Version, original) {
|
||||
t.Fatal("canonical replay", err)
|
||||
}
|
||||
in.Specification.Body.Steps[0].Task.Input.Literal = json.RawMessage(`{"n":9007199254740992}`)
|
||||
_, err = f.api.Publish(ctx, f.token, id, "release-0", in)
|
||||
expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT")
|
||||
_, err = f.api.Publish(ctx, f.token, id, "changed-content", in)
|
||||
expectWorkerCode(t, err, "STATE_CONFLICT")
|
||||
in.Version = "v2"
|
||||
v2, err := f.api.Publish(ctx, f.token, id, "release-v2", in)
|
||||
if err != nil || v2.Version.ContentDigest == original.ContentDigest {
|
||||
t.Fatal("publish v2", err)
|
||||
}
|
||||
v1, err := f.api.Version(ctx, f.token, id, "v1")
|
||||
if err != nil || !reflect.DeepEqual(v1, original) {
|
||||
t.Fatal("old version changed", err)
|
||||
}
|
||||
metadata, err := f.api.Get(ctx, f.token, id)
|
||||
if err != nil || metadata.Revision != 3 {
|
||||
t.Fatal("metadata revision", err)
|
||||
}
|
||||
if n := f.count(`SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND resource_id=? AND action='workflow.version.publish'`, f.resource.NamespaceID, id); n != 2 {
|
||||
t.Fatalf("audit=%d", n)
|
||||
}
|
||||
if n := f.count(`SELECT COUNT(*) FROM pulse_tasks WHERE namespace_id=?`, f.resource.NamespaceID); n != 0 {
|
||||
t.Fatal("publish created tasks")
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `UPDATE weave_definition_versions SET version='modified' WHERE namespace_id=? AND id=?`, f.resource.NamespaceID, original.ID); err == nil {
|
||||
t.Fatal("immutable update allowed")
|
||||
}
|
||||
})
|
||||
t.Run("authorization_and_associations_without_foreign_keys", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
g := newWorkflowFixture(t, ctx, db, false)
|
||||
id := f.create(string(executionID(t))).Definition.ID
|
||||
_, err := g.api.Create(ctx, g.token, string(executionID(t)), definition.CreateInput{Name: "forbidden"})
|
||||
expectWorkerCode(t, err, "PERMISSION_DENIED")
|
||||
in := definition.PublishInput{Version: "v1", Reason: "authorization regression", Specification: workflowTestSpec(f.resource.QueueID)}
|
||||
accepted, err := f.api.Publish(ctx, f.token, id, "pub", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
permissions := func(actions []string, queues []string) {
|
||||
raw, _ := json.Marshal(auth.Permissions{Actions: actions, Queues: queues})
|
||||
f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), f.resource.NamespaceID, f.resource.CredentialID)
|
||||
}
|
||||
permissions([]string{definition.ReadAction, definition.PublishAction}, []string{string(g.resource.QueueID)})
|
||||
if _, err := f.api.Get(ctx, f.token, id); err != nil {
|
||||
t.Fatal("metadata scope", err)
|
||||
}
|
||||
_, err = f.api.Version(ctx, f.token, id, "v1")
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
_, err = f.api.Publish(ctx, f.token, id, "pub", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
in.Version = "v2"
|
||||
in.Specification.Body.Steps[0].Task.QueueID = g.resource.QueueID
|
||||
_, err = f.api.Publish(ctx, f.token, id, "foreign-queue", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
permissions([]string{definition.ReadAction, definition.PublishAction}, []string{"*"})
|
||||
in.Specification.Body.Steps[0].Task.QueueID = executionID(t)
|
||||
_, err = f.api.Publish(ctx, f.token, id, "absent-queue", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
_, err = f.api.Publish(ctx, f.token, executionID(t), "absent-parent", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
raw, _ := json.Marshal(auth.Permissions{Actions: []string{definition.ReadAction, definition.PublishAction}, Queues: []string{"*"}})
|
||||
g.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), g.resource.NamespaceID, g.resource.CredentialID)
|
||||
_, err = g.api.Version(ctx, g.token, id, "v1")
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
// 队列被人工删除后原版本不可用,不能依赖数据库外键兜底。
|
||||
f.exec(`DELETE FROM pulse_queues WHERE namespace_id=? AND id=?`, f.resource.NamespaceID, f.resource.QueueID)
|
||||
_, err = f.api.Version(ctx, f.token, id, "v1")
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
if n := f.count(`SELECT COUNT(*) FROM weave_definition_versions WHERE namespace_id=? AND definition_id=?`, f.resource.NamespaceID, id); n != 1 {
|
||||
t.Fatal("rejected publication persisted")
|
||||
}
|
||||
if n := f.count(`SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`); n != 0 {
|
||||
t.Fatal("foreign keys exist")
|
||||
}
|
||||
if accepted.Version.ID == "" {
|
||||
t.Fatal("missing publication")
|
||||
}
|
||||
})
|
||||
t.Run("audit_failure_rolls_back_entire_publication", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
id := f.create(string(executionID(t))).Definition.ID
|
||||
in := definition.PublishInput{Version: "v1", Reason: "atomic publication", Specification: workflowTestSpec(f.resource.QueueID)}
|
||||
f.exec(`CREATE TRIGGER orbit_test_workflow_audit BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='publication audit failure'`)
|
||||
_, err := f.api.Publish(ctx, f.token, id, "atomic", in)
|
||||
f.exec(`DROP TRIGGER orbit_test_workflow_audit`)
|
||||
if err == nil {
|
||||
t.Fatal("audit failure accepted")
|
||||
}
|
||||
for _, q := range []string{`SELECT COUNT(*) FROM weave_definition_versions WHERE namespace_id=?`, `SELECT COUNT(*) FROM orbit_idempotency_keys WHERE namespace_id=? AND operation='workflow.version.publish'`} {
|
||||
if f.count(q, f.resource.NamespaceID) != 0 {
|
||||
t.Fatal("partial publication committed")
|
||||
}
|
||||
}
|
||||
metadata, err := f.api.Get(ctx, f.token, id)
|
||||
if err != nil || metadata.Revision != 1 {
|
||||
t.Fatal("revision not rolled back", err)
|
||||
}
|
||||
out, err := f.api.Publish(ctx, f.token, id, "atomic", in)
|
||||
if err != nil || out.Deduplicated {
|
||||
t.Fatal("retry rolled back publication", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("damaged_or_orphaned_versions_fail_closed", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
id := f.create(string(executionID(t))).Definition.ID
|
||||
in := definition.PublishInput{Version: "v1", Reason: "stored integrity", Specification: workflowTestSpec(f.resource.QueueID)}
|
||||
out, err := f.api.Publish(ctx, f.token, id, "original", in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 只在隔离库新增损坏记录,不停用不可变触发器;不依赖外键阻止这些写入。
|
||||
f.exec(`INSERT INTO weave_definition_versions(namespace_id,id,definition_id,version,schema_version,specification,content_digest,published_by) SELECT namespace_id,?,definition_id,'damaged',schema_version,specification,?,published_by FROM weave_definition_versions WHERE namespace_id=? AND id=?`, executionID(t), core.Digest{}, f.resource.NamespaceID, out.Version.ID)
|
||||
_, err = f.api.Version(ctx, f.token, id, "damaged")
|
||||
if err == nil {
|
||||
t.Fatal("damaged digest accepted")
|
||||
}
|
||||
f.exec(`DELETE FROM weave_definitions WHERE namespace_id=? AND id=?`, f.resource.NamespaceID, id)
|
||||
_, err = f.api.Version(ctx, f.token, id, "v1")
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
_, err = f.api.Publish(ctx, f.token, id, "original", in)
|
||||
expectWorkerCode(t, err, "NOT_FOUND")
|
||||
})
|
||||
|
||||
t.Run("bounded_metadata_and_receipts_at_capacity", func(t *testing.T) {
|
||||
f := newWorkflowFixture(t, ctx, db, true)
|
||||
first := f.create("first")
|
||||
id := first.Definition.ID
|
||||
// 使用真实API填满元数据限额;重复回执必须仍可读取。
|
||||
for i := 1; i < definition.MaxDefinitions; i++ {
|
||||
f.create(fmt.Sprintf("cap-%d", i))
|
||||
}
|
||||
_, err := f.api.Create(ctx, f.token, "overflow", definition.CreateInput{Name: "overflow"})
|
||||
expectWorkerCode(t, err, "WORKFLOW_DEFINITION_LIMIT")
|
||||
out, err := f.api.Create(ctx, f.token, "first", definition.CreateInput{Name: "flow-first", Description: "通用作业处理"})
|
||||
if err != nil || !out.Deduplicated {
|
||||
t.Fatal("create replay at limit", err)
|
||||
}
|
||||
in := definition.PublishInput{Reason: "bounded version history", Specification: workflowTestSpec(f.resource.QueueID)}
|
||||
for i := 0; i < definition.MaxVersions; i++ {
|
||||
in.Version = fmt.Sprintf("v%d", i)
|
||||
if _, err := f.api.Publish(ctx, f.token, id, in.Version, in); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
in.Version = "overflow"
|
||||
_, err = f.api.Publish(ctx, f.token, id, "overflow", in)
|
||||
expectWorkerCode(t, err, "WORKFLOW_VERSION_LIMIT")
|
||||
in.Version = "v0"
|
||||
same, err := f.api.Publish(ctx, f.token, id, "v0", in)
|
||||
if err != nil || !same.Deduplicated {
|
||||
t.Fatal("original version replay at limit", err)
|
||||
}
|
||||
same, err = f.api.Publish(ctx, f.token, id, "new-key-same-version", in)
|
||||
if err != nil || !same.Deduplicated {
|
||||
t.Fatal("same version replay at limit", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user