feat: publish immutable workflow definitions with typed SDK
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user