DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,316 @@
|
||||
package adminworkflow
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/httpx"
|
||||
)
|
||||
|
||||
const (
|
||||
defRecordLimit = 100
|
||||
maxFlowRecLimit = 500
|
||||
)
|
||||
|
||||
// AdminOptions 描述运营工作流只读后台接口的挂载方式。
|
||||
type AdminOptions struct {
|
||||
Prefix string
|
||||
Wrap func(string, http.HandlerFunc) http.HandlerFunc
|
||||
}
|
||||
|
||||
// WorkflowRecordSummary 是工作流列表页使用的轻量记录摘要。
|
||||
type WorkflowRecordSummary struct {
|
||||
ID string `json:"id"`
|
||||
Workflow string `json:"workflow,omitempty"`
|
||||
Operation string `json:"operation,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Actor string `json:"actor,omitempty"`
|
||||
IdempotencyKey string `json:"idempotency_key,omitempty"`
|
||||
Phase Phase `json:"phase"`
|
||||
ApprovalID string `json:"approval_id,omitempty"`
|
||||
ReceiptID string `json:"receipt_id,omitempty"`
|
||||
ReceiptStatus string `json:"receipt_status,omitempty"`
|
||||
HasRollbackNote bool `json:"has_rollback_note"`
|
||||
RollbackEvidenceRef string `json:"rollback_evidence_ref,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type flowRecordFilter struct {
|
||||
workflow string
|
||||
phase Phase
|
||||
operation string
|
||||
actor string
|
||||
idempotencyKey string
|
||||
approvalID string
|
||||
receiptID string
|
||||
hasRollbackNote *bool
|
||||
limit int
|
||||
}
|
||||
|
||||
// RegisterAdminRoutes 注册工作流记录和 rollback note 的只读后台接口。
|
||||
func RegisterAdminRoutes(mux *http.ServeMux, service *Service, options AdminOptions) {
|
||||
if mux == nil {
|
||||
return
|
||||
}
|
||||
prefix := strings.TrimRight(strings.TrimSpace(options.Prefix), "/")
|
||||
if prefix == "" {
|
||||
prefix = "/debug/admin/workflows"
|
||||
}
|
||||
wrap := options.Wrap
|
||||
if wrap == nil {
|
||||
wrap = func(_ string, next http.HandlerFunc) http.HandlerFunc { return next }
|
||||
}
|
||||
mux.HandleFunc(prefix, wrap("adminworkflow", func(w http.ResponseWriter, r *http.Request) {
|
||||
handleWorkflowList(w, r, service)
|
||||
}))
|
||||
mux.HandleFunc(prefix+"/", wrap("adminworkflow", func(w http.ResponseWriter, r *http.Request) {
|
||||
suffix := strings.TrimPrefix(r.URL.Path, prefix+"/")
|
||||
handleWorkflowItem(w, r, service, suffix)
|
||||
}))
|
||||
}
|
||||
|
||||
func handleWorkflowList(w http.ResponseWriter, r *http.Request, service *Service) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeMethodNA(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
filter, err := workflowFilterReq(r)
|
||||
if err != nil {
|
||||
httpx.WriteJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
records, err := service.List(r.Context())
|
||||
if err != nil {
|
||||
writeWorkflowErr(w, err)
|
||||
return
|
||||
}
|
||||
sort.SliceStable(records, func(i, j int) bool {
|
||||
left := records[i].UpdatedAt.UTC()
|
||||
right := records[j].UpdatedAt.UTC()
|
||||
if !left.Equal(right) {
|
||||
return left.After(right)
|
||||
}
|
||||
return records[i].ID < records[j].ID
|
||||
})
|
||||
summaries := make([]WorkflowRecordSummary, 0, minWorkflowInt(len(records), filter.limit))
|
||||
total := 0
|
||||
for _, record := range records {
|
||||
if !filter.match(record) {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if len(summaries) >= filter.limit {
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, WorkflowRecordSummaryFromRecord(record))
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"records": summaries,
|
||||
"count": len(summaries),
|
||||
"total": total,
|
||||
"limit": filter.limit,
|
||||
})
|
||||
}
|
||||
|
||||
func handleWorkflowItem(w http.ResponseWriter, r *http.Request, service *Service, suffix string) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
writeMethodNA(w, http.MethodGet)
|
||||
return
|
||||
}
|
||||
id, rollbackNote, ok := parseRecordItemPath(suffix)
|
||||
if !ok {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": ErrWorkflowNotFound.Error()})
|
||||
return
|
||||
}
|
||||
record, found, err := service.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeWorkflowErr(w, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": ErrWorkflowNotFound.Error()})
|
||||
return
|
||||
}
|
||||
if rollbackNote {
|
||||
if record.RollbackNote == nil || !hasRollbackNote(*record.RollbackNote) {
|
||||
httpx.WriteJSON(w, http.StatusNotFound, map[string]string{"error": "rollback note not found"})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"workflow_id": record.ID,
|
||||
"rollback_note": record.RollbackNote,
|
||||
})
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
||||
"record": record,
|
||||
"summary": WorkflowRecordSummaryFromRecord(record),
|
||||
})
|
||||
}
|
||||
|
||||
// WorkflowRecordSummaryFromRecord 从完整工作流记录生成列表摘要。
|
||||
func WorkflowRecordSummaryFromRecord(record Record) WorkflowRecordSummary {
|
||||
record = cloneRecord(record)
|
||||
summary := WorkflowRecordSummary{
|
||||
ID: strings.TrimSpace(record.ID),
|
||||
Workflow: strings.TrimSpace(record.Workflow),
|
||||
Operation: record.Command.Operation,
|
||||
Scope: record.Command.Scope,
|
||||
Target: record.Command.Target,
|
||||
Actor: record.Command.Actor,
|
||||
IdempotencyKey: record.Command.IdempotencyKey,
|
||||
Phase: record.Phase,
|
||||
Error: strings.TrimSpace(record.Error),
|
||||
CreatedAt: record.CreatedAt.UTC(),
|
||||
UpdatedAt: record.UpdatedAt.UTC(),
|
||||
}
|
||||
if record.Approval != nil {
|
||||
summary.ApprovalID = strings.TrimSpace(record.Approval.ID)
|
||||
}
|
||||
if record.Receipt != nil {
|
||||
summary.ReceiptID = strings.TrimSpace(record.Receipt.ID)
|
||||
summary.ReceiptStatus = strings.TrimSpace(record.Receipt.Status)
|
||||
}
|
||||
if record.RollbackNote != nil && hasRollbackNote(*record.RollbackNote) {
|
||||
summary.HasRollbackNote = true
|
||||
summary.RollbackEvidenceRef = strings.TrimSpace(record.RollbackNote.EvidenceRef)
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func workflowFilterReq(r *http.Request) (flowRecordFilter, error) {
|
||||
query := r.URL.Query()
|
||||
limit, err := parseRecordListLimit(query.Get("limit"))
|
||||
if err != nil {
|
||||
return flowRecordFilter{}, err
|
||||
}
|
||||
hasRollbackNote, err := parseWorkflowBool(query.Get("has_rollback_note"), "has_rollback_note")
|
||||
if err != nil {
|
||||
return flowRecordFilter{}, err
|
||||
}
|
||||
return flowRecordFilter{
|
||||
workflow: strings.TrimSpace(query.Get("workflow")),
|
||||
phase: Phase(strings.TrimSpace(query.Get("phase"))),
|
||||
operation: strings.TrimSpace(query.Get("operation")),
|
||||
actor: strings.TrimSpace(query.Get("actor")),
|
||||
idempotencyKey: strings.TrimSpace(query.Get("idempotency_key")),
|
||||
approvalID: strings.TrimSpace(query.Get("approval_id")),
|
||||
receiptID: strings.TrimSpace(query.Get("receipt_id")),
|
||||
hasRollbackNote: hasRollbackNote,
|
||||
limit: limit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f flowRecordFilter) match(record Record) bool {
|
||||
record = cloneRecord(record)
|
||||
if f.workflow != "" && record.Workflow != f.workflow {
|
||||
return false
|
||||
}
|
||||
if f.phase != "" && record.Phase != f.phase {
|
||||
return false
|
||||
}
|
||||
if f.operation != "" && record.Command.Operation != f.operation {
|
||||
return false
|
||||
}
|
||||
if f.actor != "" && record.Command.Actor != f.actor {
|
||||
return false
|
||||
}
|
||||
if f.idempotencyKey != "" && record.Command.IdempotencyKey != f.idempotencyKey {
|
||||
return false
|
||||
}
|
||||
if f.approvalID != "" {
|
||||
if record.Approval == nil || record.Approval.ID != f.approvalID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.receiptID != "" {
|
||||
if record.Receipt == nil || record.Receipt.ID != f.receiptID {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.hasRollbackNote != nil {
|
||||
hasNote := record.RollbackNote != nil && hasRollbackNote(*record.RollbackNote)
|
||||
if hasNote != *f.hasRollbackNote {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseRecordItemPath(suffix string) (string, bool, bool) {
|
||||
suffix = strings.Trim(suffix, "/")
|
||||
if suffix == "" {
|
||||
return "", false, false
|
||||
}
|
||||
rollbackNote := false
|
||||
if strings.HasSuffix(suffix, "/rollback-note") {
|
||||
rollbackNote = true
|
||||
suffix = strings.TrimSuffix(suffix, "/rollback-note")
|
||||
}
|
||||
if suffix == "" || strings.Contains(suffix, "/") {
|
||||
return "", false, false
|
||||
}
|
||||
id := strings.TrimSpace(suffix)
|
||||
if id == "" {
|
||||
return "", false, false
|
||||
}
|
||||
return id, rollbackNote, true
|
||||
}
|
||||
|
||||
func parseRecordListLimit(raw string) (int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defRecordLimit, nil
|
||||
}
|
||||
limit, err := strconv.Atoi(raw)
|
||||
if err != nil || limit < 0 {
|
||||
return 0, fmt.Errorf("%w: limit must be a non-negative integer", ErrWorkflowInvalid)
|
||||
}
|
||||
if limit > maxFlowRecLimit {
|
||||
return maxFlowRecLimit, nil
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
func parseWorkflowBool(raw, name string) (*bool, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %s must be boolean", ErrWorkflowInvalid, name)
|
||||
}
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func writeMethodNA(w http.ResponseWriter, allowed string) {
|
||||
if allowed == http.MethodGet {
|
||||
allowed = http.MethodGet + ", " + http.MethodHead
|
||||
}
|
||||
w.Header().Set("Allow", allowed)
|
||||
httpx.WriteJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||
}
|
||||
|
||||
func writeWorkflowErr(w http.ResponseWriter, err error) {
|
||||
status := http.StatusInternalServerError
|
||||
if errors.Is(err, ErrStoreRequired) {
|
||||
status = http.StatusServiceUnavailable
|
||||
}
|
||||
httpx.WriteJSON(w, status, map[string]string{"error": err.Error()})
|
||||
}
|
||||
|
||||
func minWorkflowInt(left, right int) int {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package adminworkflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const CatalogKind = "admin_operator_workflows"
|
||||
|
||||
var ErrCatalogInvalid = errors.New("admin workflow catalog is invalid")
|
||||
|
||||
type Catalog struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Kind string `json:"kind"`
|
||||
Workflows []WorkflowTemplate `json:"workflows"`
|
||||
}
|
||||
|
||||
type WorkflowTemplate struct {
|
||||
Name string `json:"name"`
|
||||
RequiredControls []string `json:"required_controls"`
|
||||
Evidence []string `json:"evidence"`
|
||||
}
|
||||
|
||||
type CatalogValidateOptions struct {
|
||||
RequiredNames []string
|
||||
}
|
||||
|
||||
func WorkflowNames() []string {
|
||||
return []string{
|
||||
"player_query",
|
||||
"session_drain_and_kick",
|
||||
"profile_repair",
|
||||
"deadletter_requeue",
|
||||
"event_replay",
|
||||
"leaderboard_correction",
|
||||
"ban_or_unban",
|
||||
"rollback_or_gray_recovery",
|
||||
}
|
||||
}
|
||||
|
||||
func LoadCatalogFile(path string) (Catalog, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304:路径来自框架配置、仓库扫描或测试临时目录,调用点负责限定输入范围。
|
||||
if err != nil {
|
||||
return Catalog{}, err
|
||||
}
|
||||
return ParseCatalog(data)
|
||||
}
|
||||
|
||||
func ParseCatalog(data []byte) (Catalog, error) {
|
||||
var catalog Catalog
|
||||
if err := json.Unmarshal(data, &catalog); err != nil {
|
||||
return Catalog{}, err
|
||||
}
|
||||
catalog = NormalizeCatalog(catalog)
|
||||
if err := ValidateCatalog(catalog, CatalogValidateOptions{RequiredNames: WorkflowNames()}); err != nil {
|
||||
return Catalog{}, err
|
||||
}
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func NormalizeCatalog(catalog Catalog) Catalog {
|
||||
catalog.Kind = strings.TrimSpace(catalog.Kind)
|
||||
for idx := range catalog.Workflows {
|
||||
catalog.Workflows[idx] = NormalizeWorkflowTemplate(catalog.Workflows[idx])
|
||||
}
|
||||
sort.SliceStable(catalog.Workflows, func(i, j int) bool { return catalog.Workflows[i].Name < catalog.Workflows[j].Name })
|
||||
return catalog
|
||||
}
|
||||
|
||||
func NormalizeWorkflowTemplate(template WorkflowTemplate) WorkflowTemplate {
|
||||
template.Name = normalizeWorkflowID(template.Name)
|
||||
template.RequiredControls = normWorkflowList(template.RequiredControls)
|
||||
template.Evidence = normWorkflowList(template.Evidence)
|
||||
return template
|
||||
}
|
||||
|
||||
func ValidateCatalog(catalog Catalog, options CatalogValidateOptions) error {
|
||||
if catalog.SchemaVersion <= 0 {
|
||||
return fmt.Errorf("%w: schema_version is required", ErrCatalogInvalid)
|
||||
}
|
||||
if catalog.Kind != CatalogKind {
|
||||
return fmt.Errorf("%w: kind must be %s", ErrCatalogInvalid, CatalogKind)
|
||||
}
|
||||
if len(catalog.Workflows) == 0 {
|
||||
return fmt.Errorf("%w: workflows is empty", ErrCatalogInvalid)
|
||||
}
|
||||
seen := make(map[string]WorkflowTemplate, len(catalog.Workflows))
|
||||
for _, template := range catalog.Workflows {
|
||||
if err := ValidateWorkflowTemplate(template); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, ok := seen[template.Name]; ok {
|
||||
return fmt.Errorf("%w: duplicate workflow %s", ErrCatalogInvalid, template.Name)
|
||||
}
|
||||
seen[template.Name] = template
|
||||
}
|
||||
for _, name := range normWorkflowList(options.RequiredNames) {
|
||||
name = normalizeWorkflowID(name)
|
||||
if _, ok := seen[name]; !ok {
|
||||
return fmt.Errorf("%w: required workflow %s is missing", ErrCatalogInvalid, name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateWorkflowTemplate(template WorkflowTemplate) error {
|
||||
template = NormalizeWorkflowTemplate(template)
|
||||
if template.Name == "" {
|
||||
return fmt.Errorf("%w: workflow name is required", ErrCatalogInvalid)
|
||||
}
|
||||
if len(template.RequiredControls) == 0 {
|
||||
return fmt.Errorf("%w: %s controls are empty", ErrCatalogInvalid, template.Name)
|
||||
}
|
||||
if len(template.Evidence) == 0 {
|
||||
return fmt.Errorf("%w: %s evidence is empty", ErrCatalogInvalid, template.Name)
|
||||
}
|
||||
controls := workflowSet(template.RequiredControls)
|
||||
if !hasWorkflowItem(controls, "RBAC") {
|
||||
return fmt.Errorf("%w: %s requires RBAC control", ErrCatalogInvalid, template.Name)
|
||||
}
|
||||
if !hasWorkflowItem(controls, "audit") {
|
||||
return fmt.Errorf("%w: %s requires audit control", ErrCatalogInvalid, template.Name)
|
||||
}
|
||||
if hasWorkflowItem(controls, "dangerous_confirmation") {
|
||||
for _, required := range []string{"idempotency_key", "command_receipt"} {
|
||||
if !hasWorkflowItem(controls, required) {
|
||||
return fmt.Errorf("%w: %s dangerous workflow requires %s", ErrCatalogInvalid, template.Name, required)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Catalog) FindWorkflow(name string) (WorkflowTemplate, bool) {
|
||||
name = normalizeWorkflowID(name)
|
||||
for _, template := range c.Workflows {
|
||||
if template.Name == name {
|
||||
return template, true
|
||||
}
|
||||
}
|
||||
return WorkflowTemplate{}, false
|
||||
}
|
||||
|
||||
func workflowSet(values []string) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value != "" {
|
||||
out[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasWorkflowItem(values map[string]struct{}, item string) bool {
|
||||
_, ok := values[strings.ToLower(strings.TrimSpace(item))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func normalizeWorkflowID(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.ReplaceAll(value, "-", "_")
|
||||
value = strings.ReplaceAll(value, " ", "_")
|
||||
return strings.ToLower(value)
|
||||
}
|
||||
|
||||
func normWorkflowList(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(value)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
package adminworkflow
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"longheng.io/server/internal/platform/admincmd"
|
||||
)
|
||||
|
||||
var ErrOperationRecipeInvalid = errors.New("admin operation recipe is invalid")
|
||||
|
||||
// OperationRecipe 描述一个可复用运营命令从参数、预览、执行到回滚证据的接入配方。
|
||||
type OperationRecipe struct {
|
||||
// Name 是运营配方名,和 admincmd 模板、adminworkflow 工作流名对齐。
|
||||
Name string `json:"name"`
|
||||
// Description 是配方用途说明。
|
||||
Description string `json:"description,omitempty"`
|
||||
// Source 是抽取来源类别,通常标记为成熟项目GM 或龙恒平台能力。
|
||||
Source string `json:"source,omitempty"`
|
||||
// Command 是对应的 admincmd 模板。
|
||||
Command admincmd.Template `json:"command"`
|
||||
// Workflow 是对应的 Admin 工作流控制要求。
|
||||
Workflow WorkflowTemplate `json:"workflow"`
|
||||
// DryRunSteps 是执行前必须产出的预览或检查步骤。
|
||||
DryRunSteps []string `json:"dry_run_steps,omitempty"`
|
||||
// ExecuteSteps 是审批后真正执行的步骤。
|
||||
ExecuteSteps []string `json:"execute_steps,omitempty"`
|
||||
// RollbackSteps 是失败或误操作后的回滚、补偿或人工修复步骤。
|
||||
RollbackSteps []string `json:"rollback_steps,omitempty"`
|
||||
// Notes 是项目接入时容易遗漏的工程注意事项。
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// DefaultOperationRecipes 返回从成熟项目GM 操作经验抽出的默认运营命令配方。
|
||||
func DefaultOperationRecipes() []OperationRecipe {
|
||||
return cloneOpRecipes(defOpRecipes)
|
||||
}
|
||||
|
||||
// FindDefaultOperationRecipe 按名称查找默认运营命令配方。
|
||||
func FindDefaultOperationRecipe(name string) (OperationRecipe, bool) {
|
||||
name = normalizeWorkflowID(name)
|
||||
for _, recipe := range defOpRecipes {
|
||||
if recipe.Name == name {
|
||||
return cloneOperationRecipe(recipe), true
|
||||
}
|
||||
}
|
||||
return OperationRecipe{}, false
|
||||
}
|
||||
|
||||
// ValidateOperationRecipe 校验运营命令配方是否具备命令模板、工作流控制、步骤和证据。
|
||||
func ValidateOperationRecipe(recipe OperationRecipe) error {
|
||||
_, err := normOpRecipe(recipe)
|
||||
return err
|
||||
}
|
||||
|
||||
// DefaultOperationCommandTemplates 返回默认运营命令配方中的 admincmd 模板。
|
||||
func DefaultOperationCommandTemplates() ([]admincmd.Template, error) {
|
||||
recipes := DefaultOperationRecipes()
|
||||
out := make([]admincmd.Template, 0, len(recipes))
|
||||
for _, recipe := range recipes {
|
||||
normalized, err := normOpRecipe(recipe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, normalized.Command)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DefaultOperationWorkflowTemplates 返回默认运营命令配方中的 Admin 工作流模板。
|
||||
func DefaultOperationWorkflowTemplates() ([]WorkflowTemplate, error) {
|
||||
recipes := DefaultOperationRecipes()
|
||||
out := make([]WorkflowTemplate, 0, len(recipes))
|
||||
for _, recipe := range recipes {
|
||||
normalized, err := normOpRecipe(recipe)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, normalized.Workflow)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var defOpRecipes = []OperationRecipe{
|
||||
{
|
||||
Name: "player_query",
|
||||
Description: "玩家基础信息、会话和 Profile 版本查询",
|
||||
Source: "成熟项目GM player/profile 查询经验",
|
||||
Command: admincmd.Template{
|
||||
Name: "player_query",
|
||||
Operation: "player.query",
|
||||
Scope: "logic",
|
||||
Target: "player",
|
||||
Params: []admincmd.ParamSpec{
|
||||
{Name: "account_id", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "shard_id", Type: admincmd.ParamString},
|
||||
},
|
||||
},
|
||||
Workflow: WorkflowTemplate{
|
||||
Name: "player_query",
|
||||
RequiredControls: []string{"RBAC", "read_only", "audit", "trace_id"},
|
||||
Evidence: []string{"player overview snapshot", "session snapshot", "profile version"},
|
||||
},
|
||||
DryRunSteps: []string{"读取玩家在线状态", "读取 Profile 版本", "读取最近会话摘要"},
|
||||
ExecuteSteps: []string{"返回只读查询结果", "记录审计查询"},
|
||||
RollbackSteps: []string{"只读操作不需要回滚"},
|
||||
Notes: []string{"查询也要审计,避免后台成为隐形数据出口"},
|
||||
},
|
||||
{
|
||||
Name: "ban_or_unban",
|
||||
Description: "封禁、解封、禁言和解除禁言",
|
||||
Source: "成熟项目GM ban 操作经验",
|
||||
Command: admincmd.Template{
|
||||
Name: "ban_or_unban",
|
||||
Operation: "moderation.sanction",
|
||||
Scope: "moderation",
|
||||
Target: "account",
|
||||
Dangerous: true,
|
||||
Params: []admincmd.ParamSpec{
|
||||
{Name: "account_id", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "action", Type: admincmd.ParamString, Required: true, Allowed: []string{"ban", "unban", "mute", "unmute"}},
|
||||
{Name: "duration_seconds", Type: admincmd.ParamInt},
|
||||
{Name: "reason_code", Type: admincmd.ParamString, Required: true},
|
||||
},
|
||||
},
|
||||
Workflow: WorkflowTemplate{
|
||||
Name: "ban_or_unban",
|
||||
RequiredControls: []string{"RBAC", "reason", "duration_or_policy", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt"},
|
||||
Evidence: []string{"moderation record", "gateway refresh", "audit query"},
|
||||
},
|
||||
DryRunSteps: []string{"查询当前处罚状态", "校验处罚策略", "生成影响范围预览"},
|
||||
ExecuteSteps: []string{"写入处罚记录", "刷新网关会话状态", "返回 command receipt"},
|
||||
RollbackSteps: []string{"按 receipt 撤销本次处罚变更", "刷新网关会话状态"},
|
||||
Notes: []string{"封禁类操作必须有原因码和审计记录"},
|
||||
},
|
||||
{
|
||||
Name: "mail_publish",
|
||||
Description: "单人邮件、全服邮件和运营公告发布",
|
||||
Source: "成熟项目GM mail/public_notice 操作经验",
|
||||
Command: admincmd.Template{
|
||||
Name: "mail_publish",
|
||||
Operation: "notice.publish",
|
||||
Scope: "logic",
|
||||
Target: "notice",
|
||||
Dangerous: true,
|
||||
Params: []admincmd.ParamSpec{
|
||||
{Name: "notice_id", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "audience", Type: admincmd.ParamString, Required: true, Allowed: []string{"account", "shard", "all"}},
|
||||
{Name: "account_id", Type: admincmd.ParamString},
|
||||
{Name: "shard_id", Type: admincmd.ParamString},
|
||||
},
|
||||
},
|
||||
Workflow: WorkflowTemplate{
|
||||
Name: "mail_publish",
|
||||
RequiredControls: []string{"dry_run", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_note"},
|
||||
Evidence: []string{"audience preview", "notice publish receipt", "delivery sample"},
|
||||
},
|
||||
DryRunSteps: []string{"校验收件范围", "抽样展示邮件内容", "检查附件幂等键"},
|
||||
ExecuteSteps: []string{"写入公告或邮件批次", "触发在线推送", "记录发放证据"},
|
||||
RollbackSteps: []string{"冻结未领取附件", "撤销未生效公告", "生成客服补偿清单"},
|
||||
Notes: []string{"邮件附件必须接入可靠发放 owner,不能只写后台记录"},
|
||||
},
|
||||
{
|
||||
Name: "profile_repair",
|
||||
Description: "玩家 Profile 检查、字段修复和批量修复",
|
||||
Source: "成熟项目GM profile 修复经验",
|
||||
Command: admincmd.Template{
|
||||
Name: "profile_repair",
|
||||
Operation: "profile.repair",
|
||||
Scope: "logic",
|
||||
Target: "player",
|
||||
Dangerous: true,
|
||||
Params: []admincmd.ParamSpec{
|
||||
{Name: "account_id", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "field", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "mode", Type: admincmd.ParamString, Required: true, Allowed: []string{"check", "repair"}},
|
||||
{Name: "expected_version", Type: admincmd.ParamString},
|
||||
},
|
||||
},
|
||||
Workflow: WorkflowTemplate{
|
||||
Name: "profile_repair",
|
||||
RequiredControls: []string{"read_only_preview", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_plan"},
|
||||
Evidence: []string{"before profile snapshot", "admin command receipt", "EventLog entry", "after profile snapshot"},
|
||||
},
|
||||
DryRunSteps: []string{"读取修复前 Profile 快照", "校验字段 owner", "生成字段差异预览"},
|
||||
ExecuteSteps: []string{"通过 owner 写路径修复字段", "写入 EventLog", "返回修复 receipt"},
|
||||
RollbackSteps: []string{"按修复前快照生成回滚命令", "校验 Profile 版本后恢复"},
|
||||
Notes: []string{"Profile 修复不能绕过 owner、幂等和 EventLog"},
|
||||
},
|
||||
{
|
||||
Name: "hotdata_publish",
|
||||
Description: "热数据发布、检查和回滚",
|
||||
Source: "成熟项目GM hotdata 操作经验",
|
||||
Command: admincmd.Template{
|
||||
Name: "hotdata_publish",
|
||||
Operation: "hotdata.publish",
|
||||
Scope: "config",
|
||||
Target: "hotdata",
|
||||
Dangerous: true,
|
||||
Params: []admincmd.ParamSpec{
|
||||
{Name: "bundle_id", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "version", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "mode", Type: admincmd.ParamString, Required: true, Allowed: []string{"dry_run", "apply", "rollback"}},
|
||||
},
|
||||
},
|
||||
Workflow: WorkflowTemplate{
|
||||
Name: "hotdata_publish",
|
||||
RequiredControls: []string{"dry_run", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_note"},
|
||||
Evidence: []string{"hotdata diff", "checksum report", "reload receipt"},
|
||||
},
|
||||
DryRunSteps: []string{"校验 bundle checksum", "生成配置差异", "检查目标服务 reload 能力"},
|
||||
ExecuteSteps: []string{"发布热数据版本", "触发受控 reload", "采集服务确认回执"},
|
||||
RollbackSteps: []string{"切回上一版本", "再次触发 reload", "验证配置 checksum"},
|
||||
Notes: []string{"热数据发布必须有差异报告和回滚 intent"},
|
||||
},
|
||||
{
|
||||
Name: "hotpatch_rollout",
|
||||
Description: "代码热补丁发布、灰度和回滚",
|
||||
Source: "成熟项目GM hotpatch 操作经验",
|
||||
Command: admincmd.Template{
|
||||
Name: "hotpatch_rollout",
|
||||
Operation: "hotpatch.rollout",
|
||||
Scope: "runtime",
|
||||
Target: "hotpatch",
|
||||
Dangerous: true,
|
||||
Params: []admincmd.ParamSpec{
|
||||
{Name: "patch_id", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "action", Type: admincmd.ParamString, Required: true, Allowed: []string{"dry_run", "rollout", "rollback"}},
|
||||
{Name: "target_nodes", Type: admincmd.ParamString},
|
||||
},
|
||||
},
|
||||
Workflow: WorkflowTemplate{
|
||||
Name: "hotpatch_rollout",
|
||||
RequiredControls: []string{"dry_run", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_note", "health verification"},
|
||||
Evidence: []string{"patch manifest", "target node list", "rollout receipt", "health after rollout"},
|
||||
},
|
||||
DryRunSteps: []string{"校验补丁签名和目标节点", "生成影响范围", "确认回滚补丁存在"},
|
||||
ExecuteSteps: []string{"按节点灰度发布补丁", "采集健康检查", "记录 rollout receipt"},
|
||||
RollbackSteps: []string{"按补丁 ID 回滚", "恢复节点健康", "输出回滚证据"},
|
||||
Notes: []string{"热补丁必须先小流量灰度,不能直接全量覆盖"},
|
||||
},
|
||||
{
|
||||
Name: "payment_replay",
|
||||
Description: "支付订单查询、补单、重发和发货回放",
|
||||
Source: "成熟项目GM pay_opt 操作经验",
|
||||
Command: admincmd.Template{
|
||||
Name: "payment_replay",
|
||||
Operation: "commerce.fulfillment.replay",
|
||||
Scope: "commerce",
|
||||
Target: "order",
|
||||
Dangerous: true,
|
||||
Params: []admincmd.ParamSpec{
|
||||
{Name: "order_id", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "channel", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "mode", Type: admincmd.ParamString, Required: true, Allowed: []string{"query", "replay", "compensate"}},
|
||||
},
|
||||
},
|
||||
Workflow: WorkflowTemplate{
|
||||
Name: "payment_replay",
|
||||
RequiredControls: []string{"order inspect", "dry_run", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "rollback_note"},
|
||||
Evidence: []string{"payment order snapshot", "fulfillment idempotency record", "delivery receipt"},
|
||||
},
|
||||
DryRunSteps: []string{"查询订单状态", "查询发货幂等记录", "判断是否允许补偿"},
|
||||
ExecuteSteps: []string{"通过发货 owner 重放", "写入补单复核记录", "返回发货 receipt"},
|
||||
RollbackSteps: []string{"冻结异常发货", "生成客服人工处理单", "保留支付复核证据"},
|
||||
Notes: []string{"支付补单不能绕过订单唯一键和发货幂等记录"},
|
||||
},
|
||||
{
|
||||
Name: "servergroup_merge",
|
||||
Description: "区服合服 dry-run、执行、回滚和证据归档",
|
||||
Source: "成熟项目GM merge 操作经验",
|
||||
Command: admincmd.Template{
|
||||
Name: "servergroup_merge",
|
||||
Operation: "servergroup.merge.workflow",
|
||||
Scope: "servergroup",
|
||||
Target: "merge",
|
||||
Dangerous: true,
|
||||
Params: []admincmd.ParamSpec{
|
||||
{Name: "merge_id", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "main_shard_id", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "shards", Type: admincmd.ParamString, Required: true},
|
||||
{Name: "mode", Type: admincmd.ParamString, Required: true, Allowed: []string{"dry_run", "apply", "rollback_dry_run", "rollback"}},
|
||||
{Name: "check_features", Type: admincmd.ParamString},
|
||||
{Name: "block_features", Type: admincmd.ParamString},
|
||||
{Name: "rollback_archive_id", Type: admincmd.ParamString},
|
||||
},
|
||||
},
|
||||
Workflow: WorkflowTemplate{
|
||||
Name: "servergroup_merge",
|
||||
RequiredControls: []string{"merge dry_run", "module report", "RBAC", "dangerous_confirmation", "idempotency_key", "audit", "command_receipt", "adminworkflow_adapter", "merge_workflow_runner", "merge_workflow_archive", "archive_store", "archive_query_api", "workflow_record_query_api", "rollback_note"},
|
||||
Evidence: []string{"adminworkflow.DryRun", "adminworkflow.Record", "adminworkflow.RollbackNote", "MigrationPlan", "MergeArchive", "MergeModuleRunReport", "RollbackPoint"},
|
||||
},
|
||||
DryRunSteps: []string{"生成平台 MigrationPlan", "执行模块 GenDB 报告", "保存 dry-run 归档"},
|
||||
ExecuteSteps: []string{"校验审批和幂等键", "执行模块 Merge 报告", "应用平台路由并保存归档"},
|
||||
RollbackSteps: []string{"读取 apply 归档", "执行模块 Rollback 报告", "恢复平台 RollbackPoint"},
|
||||
Notes: []string{"合服必须保留模块报告、平台回滚点和后台可查询归档"},
|
||||
},
|
||||
}
|
||||
|
||||
func normOpRecipe(recipe OperationRecipe) (OperationRecipe, error) {
|
||||
recipe.Name = normalizeWorkflowID(recipe.Name)
|
||||
recipe.Command = normCmdTemplate(recipe.Command)
|
||||
recipe.Workflow = NormalizeWorkflowTemplate(recipe.Workflow)
|
||||
recipe.DryRunSteps = normWorkflowList(recipe.DryRunSteps)
|
||||
recipe.ExecuteSteps = normWorkflowList(recipe.ExecuteSteps)
|
||||
recipe.RollbackSteps = normWorkflowList(recipe.RollbackSteps)
|
||||
recipe.Notes = normWorkflowList(recipe.Notes)
|
||||
if recipe.Name == "" {
|
||||
return OperationRecipe{}, fmt.Errorf("%w: name is required", ErrOperationRecipeInvalid)
|
||||
}
|
||||
if recipe.Command.Name != recipe.Name {
|
||||
return OperationRecipe{}, fmt.Errorf("%w: command template name must match %s", ErrOperationRecipeInvalid, recipe.Name)
|
||||
}
|
||||
if recipe.Workflow.Name != recipe.Name {
|
||||
return OperationRecipe{}, fmt.Errorf("%w: workflow template name must match %s", ErrOperationRecipeInvalid, recipe.Name)
|
||||
}
|
||||
if len(recipe.DryRunSteps) == 0 || len(recipe.ExecuteSteps) == 0 || len(recipe.RollbackSteps) == 0 {
|
||||
return OperationRecipe{}, fmt.Errorf("%w: %s steps are required", ErrOperationRecipeInvalid, recipe.Name)
|
||||
}
|
||||
if err := ValidateWorkflowTemplate(recipe.Workflow); err != nil {
|
||||
return OperationRecipe{}, fmt.Errorf("%w: %w", ErrOperationRecipeInvalid, err)
|
||||
}
|
||||
if recipe.Command.Operation == "" || recipe.Command.Target == "" {
|
||||
return OperationRecipe{}, fmt.Errorf("%w: %s command operation and target are required", ErrOperationRecipeInvalid, recipe.Name)
|
||||
}
|
||||
if recipe.Command.Dangerous {
|
||||
controls := workflowSet(recipe.Workflow.RequiredControls)
|
||||
for _, required := range []string{"dangerous_confirmation", "idempotency_key", "command_receipt"} {
|
||||
if !hasWorkflowItem(controls, required) {
|
||||
return OperationRecipe{}, fmt.Errorf("%w: dangerous recipe %s missing %s", ErrOperationRecipeInvalid, recipe.Name, required)
|
||||
}
|
||||
}
|
||||
}
|
||||
return recipe, nil
|
||||
}
|
||||
|
||||
func normCmdTemplate(template admincmd.Template) admincmd.Template {
|
||||
registry, err := admincmd.NewTemplateRegistry(template)
|
||||
if err != nil {
|
||||
return template
|
||||
}
|
||||
normalized, ok := registry.Get(template.Name)
|
||||
if !ok {
|
||||
return template
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func cloneOpRecipes(recipes []OperationRecipe) []OperationRecipe {
|
||||
if len(recipes) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]OperationRecipe, len(recipes))
|
||||
for index, recipe := range recipes {
|
||||
out[index] = cloneOperationRecipe(recipe)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneOperationRecipe(recipe OperationRecipe) OperationRecipe {
|
||||
recipe.Command.Params = append([]admincmd.ParamSpec(nil), recipe.Command.Params...)
|
||||
recipe.Workflow.RequiredControls = append([]string(nil), recipe.Workflow.RequiredControls...)
|
||||
recipe.Workflow.Evidence = append([]string(nil), recipe.Workflow.Evidence...)
|
||||
recipe.DryRunSteps = append([]string(nil), recipe.DryRunSteps...)
|
||||
recipe.ExecuteSteps = append([]string(nil), recipe.ExecuteSteps...)
|
||||
recipe.RollbackSteps = append([]string(nil), recipe.RollbackSteps...)
|
||||
recipe.Notes = append([]string(nil), recipe.Notes...)
|
||||
return recipe
|
||||
}
|
||||
|
||||
func sortedOpNames(recipes []OperationRecipe) []string {
|
||||
names := make([]string, 0, len(recipes))
|
||||
for _, recipe := range recipes {
|
||||
names = append(names, recipe.Name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package adminworkflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/admincmd"
|
||||
"longheng.io/server/internal/platform/audit"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrServiceRequired = errors.New("admin workflow service is required")
|
||||
ErrCatalogRequired = errors.New("admin workflow catalog is required")
|
||||
ErrRBACRequired = errors.New("admin workflow RBAC checker is required")
|
||||
ErrAuditRequired = errors.New("admin workflow audit logger is required")
|
||||
ErrRBACDenied = errors.New("admin workflow RBAC denied")
|
||||
)
|
||||
|
||||
const (
|
||||
ActionDryRun = "dry_run"
|
||||
ActionSubmitApproval = "submit_approval"
|
||||
ActionApprove = "approve"
|
||||
ActionReject = "reject"
|
||||
ActionExecute = "execute"
|
||||
)
|
||||
|
||||
type RBACChecker interface {
|
||||
CheckAdminWorkflow(context.Context, WorkflowTemplate, admincmd.Command, string, string) error
|
||||
}
|
||||
|
||||
type RBACFunc func(context.Context, WorkflowTemplate, admincmd.Command, string, string) error
|
||||
|
||||
func (fn RBACFunc) CheckAdminWorkflow(ctx context.Context, template WorkflowTemplate, command admincmd.Command, action, actor string) error {
|
||||
if fn == nil {
|
||||
return ErrRBACRequired
|
||||
}
|
||||
return fn(ctx, template, command, action, actor)
|
||||
}
|
||||
|
||||
type OrchestratorOptions struct {
|
||||
Service *Service
|
||||
Catalog Catalog
|
||||
RBAC RBACChecker
|
||||
Audit *audit.Logger
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Orchestrator struct {
|
||||
service *Service
|
||||
catalog Catalog
|
||||
rbac RBACChecker
|
||||
audit *audit.Logger
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
type BeginRequest struct {
|
||||
Workflow string
|
||||
Command admincmd.Command
|
||||
Previewer Previewer
|
||||
SubmitApproval bool
|
||||
}
|
||||
|
||||
type DecisionRequest struct {
|
||||
ID string
|
||||
Decision admincmd.ApprovalDecision
|
||||
}
|
||||
|
||||
type ExecuteRequest struct {
|
||||
ID string
|
||||
Actor string
|
||||
Executor Executor
|
||||
}
|
||||
|
||||
func NewOrchestrator(options OrchestratorOptions) (*Orchestrator, error) {
|
||||
if options.Service == nil {
|
||||
return nil, ErrServiceRequired
|
||||
}
|
||||
if len(options.Catalog.Workflows) == 0 {
|
||||
return nil, ErrCatalogRequired
|
||||
}
|
||||
if options.RBAC == nil {
|
||||
return nil, ErrRBACRequired
|
||||
}
|
||||
if options.Audit == nil {
|
||||
return nil, ErrAuditRequired
|
||||
}
|
||||
if err := ValidateCatalog(options.Catalog, CatalogValidateOptions{RequiredNames: WorkflowNames()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Orchestrator{
|
||||
service: options.Service,
|
||||
catalog: NormalizeCatalog(options.Catalog),
|
||||
rbac: options.RBAC,
|
||||
audit: options.Audit,
|
||||
now: now,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) Begin(ctx context.Context, request BeginRequest) (StartResult, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
template, command, err := o.prepare(ctx, request.Workflow, request.Command, ActionDryRun, request.Command.Actor)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
if request.Previewer == nil {
|
||||
return StartResult{}, ErrPreviewerRequired
|
||||
}
|
||||
if err := o.recordAudit(ctx, ActionDryRun+".requested", template, command, command.Actor, nil, ""); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
result, err := o.service.StartDryRun(ctx, command, request.Previewer)
|
||||
if err != nil {
|
||||
_ = o.recordAudit(ctx, ActionDryRun+".failed", template, command, command.Actor, nil, err.Error())
|
||||
return StartResult{}, err
|
||||
}
|
||||
record := result.Record
|
||||
record.Workflow = template.Name
|
||||
record.UpdatedAt = o.now().UTC()
|
||||
record, err = o.service.store.SaveWorkflow(ctx, record)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
result.Record = record
|
||||
if err := o.recordAudit(ctx, ActionDryRun+".succeeded", template, command, command.Actor, &record, ""); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
if !request.SubmitApproval {
|
||||
return result, nil
|
||||
}
|
||||
record, err = o.SubmitApproval(ctx, record.ID)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
result.Record = record
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) SubmitApproval(ctx context.Context, id string) (Record, error) {
|
||||
record, template, err := o.recordForAction(ctx, id, ActionSubmitApproval, "")
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if err := o.recordAudit(ctx, ActionSubmitApproval+".requested", template, record.Command, record.Command.Actor, &record, ""); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
record, err = o.service.SubmitApproval(ctx, id)
|
||||
if err != nil {
|
||||
_ = o.recordAudit(ctx, ActionSubmitApproval+".failed", template, record.Command, record.Command.Actor, &record, err.Error())
|
||||
return Record{}, err
|
||||
}
|
||||
record.Workflow = template.Name
|
||||
record, err = o.service.store.SaveWorkflow(ctx, record)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if err := o.recordAudit(ctx, ActionSubmitApproval+".succeeded", template, record.Command, record.Command.Actor, &record, ""); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) Approve(ctx context.Context, request DecisionRequest) (Record, error) {
|
||||
record, template, err := o.recordForAction(ctx, request.ID, ActionApprove, request.Decision.Approver)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if err := o.recordAudit(ctx, ActionApprove+".requested", template, record.Command, request.Decision.Approver, &record, ""); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
record, err = o.service.Approve(ctx, request.ID, request.Decision)
|
||||
if err != nil {
|
||||
_ = o.recordAudit(ctx, ActionApprove+".failed", template, record.Command, request.Decision.Approver, &record, err.Error())
|
||||
return Record{}, err
|
||||
}
|
||||
record.Workflow = template.Name
|
||||
record, err = o.service.store.SaveWorkflow(ctx, record)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if err := o.recordAudit(ctx, ActionApprove+".succeeded", template, record.Command, request.Decision.Approver, &record, ""); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) Reject(ctx context.Context, request DecisionRequest) (Record, error) {
|
||||
record, template, err := o.recordForAction(ctx, request.ID, ActionReject, request.Decision.Approver)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if err := o.recordAudit(ctx, ActionReject+".requested", template, record.Command, request.Decision.Approver, &record, ""); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
record, err = o.service.Reject(ctx, request.ID, request.Decision)
|
||||
if err != nil {
|
||||
_ = o.recordAudit(ctx, ActionReject+".failed", template, record.Command, request.Decision.Approver, &record, err.Error())
|
||||
return Record{}, err
|
||||
}
|
||||
record.Workflow = template.Name
|
||||
record, err = o.service.store.SaveWorkflow(ctx, record)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if err := o.recordAudit(ctx, ActionReject+".succeeded", template, record.Command, request.Decision.Approver, &record, ""); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) Execute(ctx context.Context, request ExecuteRequest) (Record, error) {
|
||||
record, template, err := o.recordForAction(ctx, request.ID, ActionExecute, request.Actor)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if request.Executor == nil {
|
||||
return Record{}, ErrExecutorRequired
|
||||
}
|
||||
if err := o.recordAudit(ctx, ActionExecute+".requested", template, record.Command, request.Actor, &record, ""); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
record, err = o.service.Execute(ctx, request.ID, request.Executor)
|
||||
if err != nil {
|
||||
if latest, ok, getErr := o.service.Get(ctx, request.ID); getErr == nil && ok {
|
||||
record = latest
|
||||
}
|
||||
_ = o.recordAudit(ctx, ActionExecute+".failed", template, record.Command, request.Actor, &record, err.Error())
|
||||
return Record{}, err
|
||||
}
|
||||
record.Workflow = template.Name
|
||||
record, err = o.service.store.SaveWorkflow(ctx, record)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if err := o.recordAudit(ctx, ActionExecute+".succeeded", template, record.Command, request.Actor, &record, ""); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) prepare(ctx context.Context, workflow string, command admincmd.Command, action, actor string) (WorkflowTemplate, admincmd.Command, error) {
|
||||
if o == nil || o.service == nil {
|
||||
return WorkflowTemplate{}, admincmd.Command{}, ErrServiceRequired
|
||||
}
|
||||
command = admincmd.Normalize(command)
|
||||
template, err := o.templateFor(workflow, command)
|
||||
if err != nil {
|
||||
return WorkflowTemplate{}, admincmd.Command{}, err
|
||||
}
|
||||
if err := o.rbac.CheckAdminWorkflow(ctx, template, command, action, strings.TrimSpace(actor)); err != nil {
|
||||
return WorkflowTemplate{}, admincmd.Command{}, fmt.Errorf("%w: %w", ErrRBACDenied, err)
|
||||
}
|
||||
return template, command, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) recordForAction(ctx context.Context, id, action, actor string) (Record, WorkflowTemplate, error) {
|
||||
if o == nil || o.service == nil {
|
||||
return Record{}, WorkflowTemplate{}, ErrServiceRequired
|
||||
}
|
||||
record, err := o.service.mustGet(ctx, id)
|
||||
if err != nil {
|
||||
return Record{}, WorkflowTemplate{}, err
|
||||
}
|
||||
if actor == "" {
|
||||
actor = record.Command.Actor
|
||||
}
|
||||
template, command, err := o.prepare(ctx, record.Workflow, record.Command, action, actor)
|
||||
if err != nil {
|
||||
return Record{}, WorkflowTemplate{}, err
|
||||
}
|
||||
record.Command = command
|
||||
if record.Workflow == "" {
|
||||
record.Workflow = template.Name
|
||||
}
|
||||
return record, template, nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) templateFor(workflow string, command admincmd.Command) (WorkflowTemplate, error) {
|
||||
if o == nil || len(o.catalog.Workflows) == 0 {
|
||||
return WorkflowTemplate{}, ErrCatalogRequired
|
||||
}
|
||||
names := []string{workflow}
|
||||
if command.Operation != "" {
|
||||
names = append(names, strings.ReplaceAll(command.Operation, ".", "_"))
|
||||
}
|
||||
if command.Scope != "" {
|
||||
names = append(names, command.Scope)
|
||||
}
|
||||
for _, name := range names {
|
||||
if template, ok := o.catalog.FindWorkflow(name); ok {
|
||||
return template, nil
|
||||
}
|
||||
}
|
||||
return WorkflowTemplate{}, ErrWorkflowNotFound
|
||||
}
|
||||
|
||||
func (o *Orchestrator) recordAudit(ctx context.Context, action string, template WorkflowTemplate, command admincmd.Command, actor string, record *Record, errText string) error {
|
||||
if o == nil || o.audit == nil {
|
||||
return ErrAuditRequired
|
||||
}
|
||||
fields := map[string]string{
|
||||
"workflow": template.Name,
|
||||
"operation": command.Operation,
|
||||
"scope": command.Scope,
|
||||
"environment": command.Environment,
|
||||
"shard_id": command.ShardID,
|
||||
"idempotency_key": command.IdempotencyKey,
|
||||
}
|
||||
if record != nil {
|
||||
fields["workflow_id"] = record.ID
|
||||
fields["phase"] = string(record.Phase)
|
||||
if record.Receipt != nil {
|
||||
fields["receipt_id"] = record.Receipt.ID
|
||||
fields["receipt_status"] = record.Receipt.Status
|
||||
}
|
||||
}
|
||||
if errText != "" {
|
||||
fields["error"] = errText
|
||||
}
|
||||
hash, err := admincmd.ParamsHash(command.Params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hash != "" {
|
||||
fields["params_hash"] = hash
|
||||
}
|
||||
actor = strings.TrimSpace(actor)
|
||||
if actor == "" {
|
||||
actor = command.Actor
|
||||
}
|
||||
return o.audit.Record(ctx, audit.Event{
|
||||
Time: o.now().UTC(),
|
||||
Actor: actor,
|
||||
Action: action,
|
||||
Target: command.Target,
|
||||
Fields: fields,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
package adminworkflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/admincmd"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWorkflowNotFound = errors.New("admin workflow not found")
|
||||
ErrWorkflowInvalid = errors.New("admin workflow is invalid")
|
||||
ErrWorkflowPhase = errors.New("admin workflow phase is invalid")
|
||||
ErrPreviewerRequired = errors.New("admin workflow previewer is required")
|
||||
ErrExecutorRequired = errors.New("admin workflow executor is required")
|
||||
ErrApprovalStoreRequired = errors.New("admin workflow approval store is required")
|
||||
ErrDryRunWarnings = errors.New("admin workflow dry-run has blocking warnings")
|
||||
ErrStoreRequired = errors.New("admin workflow store is required")
|
||||
)
|
||||
|
||||
type Phase string
|
||||
|
||||
const (
|
||||
PhaseDryRun Phase = "dry_run"
|
||||
PhasePendingApproval Phase = "pending_approval"
|
||||
PhaseApproved Phase = "approved"
|
||||
PhaseRejected Phase = "rejected"
|
||||
PhaseExecuted Phase = "executed"
|
||||
PhaseFailed Phase = "failed"
|
||||
PhaseRollbackNoted Phase = "rollback_noted"
|
||||
)
|
||||
|
||||
type Diff struct {
|
||||
Path string `json:"path"`
|
||||
Before any `json:"before,omitempty"`
|
||||
After any `json:"after,omitempty"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
}
|
||||
|
||||
type DryRun struct {
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Diffs []Diff `json:"diffs,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type RollbackNote struct {
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Steps []string `json:"steps,omitempty"`
|
||||
EvidenceRef string `json:"evidence_ref,omitempty"`
|
||||
Meta map[string]string `json:"meta,omitempty"`
|
||||
}
|
||||
|
||||
type Record struct {
|
||||
ID string `json:"id"`
|
||||
Workflow string `json:"workflow,omitempty"`
|
||||
Command admincmd.Command `json:"command"`
|
||||
Phase Phase `json:"phase"`
|
||||
DryRun DryRun `json:"dry_run,omitempty"`
|
||||
Approval *admincmd.ApprovalRequest `json:"approval,omitempty"`
|
||||
Receipt *admincmd.Receipt `json:"receipt,omitempty"`
|
||||
RollbackNote *RollbackNote `json:"rollback_note,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type StartResult struct {
|
||||
Record Record `json:"record"`
|
||||
Duplicate bool `json:"duplicate,omitempty"`
|
||||
}
|
||||
|
||||
type Previewer interface {
|
||||
DryRunAdminWorkflow(context.Context, admincmd.Command) (DryRun, error)
|
||||
}
|
||||
|
||||
type PreviewerFunc func(context.Context, admincmd.Command) (DryRun, error)
|
||||
|
||||
func (fn PreviewerFunc) DryRunAdminWorkflow(ctx context.Context, command admincmd.Command) (DryRun, error) {
|
||||
if fn == nil {
|
||||
return DryRun{}, ErrPreviewerRequired
|
||||
}
|
||||
return fn(ctx, command)
|
||||
}
|
||||
|
||||
type Executor interface {
|
||||
ExecuteAdminWorkflow(context.Context, Record) (admincmd.Receipt, RollbackNote, error)
|
||||
}
|
||||
|
||||
type ExecutorFunc func(context.Context, Record) (admincmd.Receipt, RollbackNote, error)
|
||||
|
||||
func (fn ExecutorFunc) ExecuteAdminWorkflow(ctx context.Context, record Record) (admincmd.Receipt, RollbackNote, error) {
|
||||
if fn == nil {
|
||||
return admincmd.Receipt{}, RollbackNote{}, ErrExecutorRequired
|
||||
}
|
||||
return fn(ctx, record)
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
SaveWorkflow(context.Context, Record) (Record, error)
|
||||
GetWorkflow(context.Context, string) (Record, bool, error)
|
||||
ListWorkflows(context.Context) ([]Record, error)
|
||||
}
|
||||
|
||||
type Policy struct {
|
||||
CommandPolicy admincmd.Policy
|
||||
RequireApproval bool
|
||||
BlockWarnings bool
|
||||
}
|
||||
|
||||
func DangerousPolicy() Policy {
|
||||
return Policy{
|
||||
CommandPolicy: admincmd.DangerousApprovalPolicy().CommandPolicy,
|
||||
RequireApproval: true,
|
||||
BlockWarnings: false,
|
||||
}
|
||||
}
|
||||
|
||||
type ServiceOptions struct {
|
||||
Store Store
|
||||
Approval *admincmd.ApprovalStore
|
||||
Policy Policy
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
store Store
|
||||
approval *admincmd.ApprovalStore
|
||||
policy Policy
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewService(options ServiceOptions) *Service {
|
||||
policy := options.Policy
|
||||
if policy.CommandPolicy == (admincmd.Policy{}) {
|
||||
policy = DangerousPolicy()
|
||||
}
|
||||
now := options.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &Service{
|
||||
store: options.Store,
|
||||
approval: options.Approval,
|
||||
policy: policy,
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) StartDryRun(ctx context.Context, command admincmd.Command, previewer Previewer) (StartResult, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return StartResult{}, ErrStoreRequired
|
||||
}
|
||||
if previewer == nil {
|
||||
return StartResult{}, ErrPreviewerRequired
|
||||
}
|
||||
command = admincmd.Normalize(command)
|
||||
if err := admincmd.Validate(command, s.policy.CommandPolicy); err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
id, err := WorkflowID(command)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
if current, ok, err := s.store.GetWorkflow(ctx, id); err != nil || ok {
|
||||
return StartResult{Record: current, Duplicate: ok}, err
|
||||
}
|
||||
dryRun, err := previewer.DryRunAdminWorkflow(ctx, command)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
dryRun = normalizeDryRun(dryRun)
|
||||
if !hasDryRunPreview(dryRun) {
|
||||
return StartResult{}, fmt.Errorf("%w: dry-run must include summary or diffs", ErrWorkflowInvalid)
|
||||
}
|
||||
if s.policy.BlockWarnings && len(dryRun.Warnings) > 0 {
|
||||
return StartResult{}, fmt.Errorf("%w: %s", ErrDryRunWarnings, strings.Join(dryRun.Warnings, "; "))
|
||||
}
|
||||
now := s.now().UTC()
|
||||
record := Record{
|
||||
ID: id,
|
||||
Command: cloneCommand(command),
|
||||
Phase: PhaseDryRun,
|
||||
DryRun: dryRun,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
record, err = s.store.SaveWorkflow(ctx, record)
|
||||
if err != nil {
|
||||
return StartResult{}, err
|
||||
}
|
||||
return StartResult{Record: record}, nil
|
||||
}
|
||||
|
||||
func (s *Service) SubmitApproval(ctx context.Context, id string) (Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return Record{}, ErrStoreRequired
|
||||
}
|
||||
if s.approval == nil {
|
||||
return Record{}, ErrApprovalStoreRequired
|
||||
}
|
||||
record, err := s.mustGet(ctx, id)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if record.Phase != PhaseDryRun {
|
||||
if record.Approval != nil {
|
||||
return cloneRecord(record), nil
|
||||
}
|
||||
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhaseDryRun, record.Phase)
|
||||
}
|
||||
request, err := s.approval.Submit(ctx, record.Command)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
record.Approval = &request
|
||||
record.Phase = PhasePendingApproval
|
||||
record.UpdatedAt = s.now().UTC()
|
||||
return s.store.SaveWorkflow(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) Approve(ctx context.Context, id string, decision admincmd.ApprovalDecision) (Record, error) {
|
||||
return s.decide(ctx, id, decision, true)
|
||||
}
|
||||
|
||||
func (s *Service) Reject(ctx context.Context, id string, decision admincmd.ApprovalDecision) (Record, error) {
|
||||
return s.decide(ctx, id, decision, false)
|
||||
}
|
||||
|
||||
func (s *Service) Execute(ctx context.Context, id string, executor Executor) (Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return Record{}, ErrStoreRequired
|
||||
}
|
||||
if executor == nil {
|
||||
return Record{}, ErrExecutorRequired
|
||||
}
|
||||
record, err := s.mustGet(ctx, id)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if record.Phase == PhaseExecuted || record.Phase == PhaseRollbackNoted {
|
||||
return record, nil
|
||||
}
|
||||
if s.policy.RequireApproval {
|
||||
if record.Phase != PhaseApproved && record.Phase != PhaseFailed {
|
||||
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhaseApproved, record.Phase)
|
||||
}
|
||||
if s.approval == nil || record.Approval == nil {
|
||||
return Record{}, ErrApprovalStoreRequired
|
||||
}
|
||||
approvalReceipt, err := s.approval.ExecuteApproved(ctx, record.Approval.ID)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
request, ok := s.approval.Get(record.Approval.ID)
|
||||
if ok {
|
||||
record.Approval = &request
|
||||
record.Command = cloneCommand(request.Command)
|
||||
}
|
||||
if record.Receipt == nil {
|
||||
record.Receipt = &approvalReceipt
|
||||
}
|
||||
} else if record.Phase != PhaseDryRun && record.Phase != PhaseFailed {
|
||||
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhaseDryRun, record.Phase)
|
||||
}
|
||||
|
||||
receipt, rollback, err := executor.ExecuteAdminWorkflow(ctx, cloneRecord(record))
|
||||
if err != nil {
|
||||
record.Phase = PhaseFailed
|
||||
record.Error = strings.TrimSpace(err.Error())
|
||||
record.UpdatedAt = s.now().UTC()
|
||||
_, _ = s.store.SaveWorkflow(ctx, record)
|
||||
return Record{}, err
|
||||
}
|
||||
if receipt.ID == "" {
|
||||
receipt = fallbackReceipt(record.Command, s.now)
|
||||
}
|
||||
record.Receipt = &receipt
|
||||
rollback = normRollbackNote(rollback)
|
||||
if hasRollbackNote(rollback) {
|
||||
record.RollbackNote = &rollback
|
||||
record.Phase = PhaseRollbackNoted
|
||||
} else {
|
||||
record.Phase = PhaseExecuted
|
||||
}
|
||||
record.Error = ""
|
||||
record.UpdatedAt = s.now().UTC()
|
||||
return s.store.SaveWorkflow(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, id string) (Record, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return Record{}, false, ErrStoreRequired
|
||||
}
|
||||
return s.store.GetWorkflow(ctx, strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return nil, ErrStoreRequired
|
||||
}
|
||||
return s.store.ListWorkflows(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) decide(ctx context.Context, id string, decision admincmd.ApprovalDecision, approve bool) (Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if s == nil || s.store == nil {
|
||||
return Record{}, ErrStoreRequired
|
||||
}
|
||||
if s.approval == nil {
|
||||
return Record{}, ErrApprovalStoreRequired
|
||||
}
|
||||
record, err := s.mustGet(ctx, id)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if record.Phase != PhasePendingApproval || record.Approval == nil {
|
||||
return Record{}, fmt.Errorf("%w: expected %s got %s", ErrWorkflowPhase, PhasePendingApproval, record.Phase)
|
||||
}
|
||||
var request admincmd.ApprovalRequest
|
||||
if approve {
|
||||
request, err = s.approval.Approve(ctx, record.Approval.ID, decision)
|
||||
record.Phase = PhaseApproved
|
||||
} else {
|
||||
request, err = s.approval.Reject(ctx, record.Approval.ID, decision)
|
||||
record.Phase = PhaseRejected
|
||||
}
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
record.Approval = &request
|
||||
record.Command = cloneCommand(request.Command)
|
||||
record.UpdatedAt = s.now().UTC()
|
||||
return s.store.SaveWorkflow(ctx, record)
|
||||
}
|
||||
|
||||
func (s *Service) mustGet(ctx context.Context, id string) (Record, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return Record{}, ErrWorkflowNotFound
|
||||
}
|
||||
record, ok, err := s.store.GetWorkflow(ctx, id)
|
||||
if err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if !ok {
|
||||
return Record{}, ErrWorkflowNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func WorkflowID(command admincmd.Command) (string, error) {
|
||||
command = admincmd.Normalize(command)
|
||||
paramsHash, err := admincmd.ParamsHash(command.Params)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if command.Operation == "" || command.IdempotencyKey == "" {
|
||||
return "", ErrWorkflowInvalid
|
||||
}
|
||||
seed := strings.Join([]string{
|
||||
command.Operation,
|
||||
command.Scope,
|
||||
command.Environment,
|
||||
command.ShardID,
|
||||
command.Target,
|
||||
command.Actor,
|
||||
command.IdempotencyKey,
|
||||
paramsHash,
|
||||
}, "|")
|
||||
sum := sha256.Sum256([]byte(seed))
|
||||
return "workflow-" + hex.EncodeToString(sum[:12]), nil
|
||||
}
|
||||
|
||||
type MemoryStore struct {
|
||||
mu sync.Mutex
|
||||
records map[string]Record
|
||||
}
|
||||
|
||||
func NewMemoryStore() *MemoryStore {
|
||||
return &MemoryStore{records: make(map[string]Record)}
|
||||
}
|
||||
|
||||
func (s *MemoryStore) SaveWorkflow(ctx context.Context, record Record) (Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, err
|
||||
}
|
||||
if s == nil {
|
||||
return Record{}, ErrStoreRequired
|
||||
}
|
||||
record.ID = strings.TrimSpace(record.ID)
|
||||
if record.ID == "" || record.Phase == "" {
|
||||
return Record{}, ErrWorkflowInvalid
|
||||
}
|
||||
record = cloneRecord(record)
|
||||
s.mu.Lock()
|
||||
if s.records == nil {
|
||||
s.records = make(map[string]Record)
|
||||
}
|
||||
s.records[record.ID] = record
|
||||
s.mu.Unlock()
|
||||
return cloneRecord(record), nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) GetWorkflow(ctx context.Context, id string) (Record, bool, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return Record{}, false, err
|
||||
}
|
||||
if s == nil {
|
||||
return Record{}, false, ErrStoreRequired
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
s.mu.Lock()
|
||||
record, ok := s.records[id]
|
||||
s.mu.Unlock()
|
||||
return cloneRecord(record), ok, nil
|
||||
}
|
||||
|
||||
func (s *MemoryStore) ListWorkflows(ctx context.Context) ([]Record, error) {
|
||||
if err := ctxErr(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s == nil {
|
||||
return nil, ErrStoreRequired
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]Record, 0, len(s.records))
|
||||
for _, record := range s.records {
|
||||
out = append(out, cloneRecord(record))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func fallbackReceipt(command admincmd.Command, now func() time.Time) admincmd.Receipt {
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
receipt, err := admincmd.NewReceipt(command, "executed", now().UTC())
|
||||
if err != nil {
|
||||
return admincmd.Receipt{Status: "executed", CreatedAt: now().UTC()}
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
|
||||
func normalizeDryRun(dryRun DryRun) DryRun {
|
||||
dryRun.Summary = strings.TrimSpace(dryRun.Summary)
|
||||
dryRun.Diffs = cloneDiffs(dryRun.Diffs)
|
||||
dryRun.Warnings = normalizeStrings(dryRun.Warnings)
|
||||
dryRun.Meta = cloneStringMap(dryRun.Meta)
|
||||
return dryRun
|
||||
}
|
||||
|
||||
func normRollbackNote(note RollbackNote) RollbackNote {
|
||||
note.Summary = strings.TrimSpace(note.Summary)
|
||||
note.Steps = normalizeStrings(note.Steps)
|
||||
note.EvidenceRef = strings.TrimSpace(note.EvidenceRef)
|
||||
note.Meta = cloneStringMap(note.Meta)
|
||||
return note
|
||||
}
|
||||
|
||||
func hasRollbackNote(note RollbackNote) bool {
|
||||
return note.Summary != "" || len(note.Steps) > 0 || note.EvidenceRef != "" || len(note.Meta) > 0
|
||||
}
|
||||
|
||||
func hasDryRunPreview(dryRun DryRun) bool {
|
||||
return dryRun.Summary != "" || len(dryRun.Diffs) > 0
|
||||
}
|
||||
|
||||
func cloneRecord(record Record) Record {
|
||||
record.Command = cloneCommand(record.Command)
|
||||
record.DryRun = normalizeDryRun(record.DryRun)
|
||||
if record.Approval != nil {
|
||||
approval := *record.Approval
|
||||
approval.Command = cloneCommand(approval.Command)
|
||||
if approval.Receipt != nil {
|
||||
receipt := *approval.Receipt
|
||||
approval.Receipt = &receipt
|
||||
}
|
||||
record.Approval = &approval
|
||||
}
|
||||
if record.Receipt != nil {
|
||||
receipt := *record.Receipt
|
||||
record.Receipt = &receipt
|
||||
}
|
||||
if record.RollbackNote != nil {
|
||||
note := normRollbackNote(*record.RollbackNote)
|
||||
record.RollbackNote = ¬e
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func cloneCommand(command admincmd.Command) admincmd.Command {
|
||||
command = admincmd.Normalize(command)
|
||||
command.Params = cloneParams(command.Params)
|
||||
return command
|
||||
}
|
||||
|
||||
func cloneParams(params map[string]any) map[string]any {
|
||||
if len(params) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(params))
|
||||
for key, value := range params {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDiffs(diffs []Diff) []Diff {
|
||||
if len(diffs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]Diff, 0, len(diffs))
|
||||
for _, diff := range diffs {
|
||||
diff.Path = strings.TrimSpace(diff.Path)
|
||||
diff.Risk = strings.TrimSpace(diff.Risk)
|
||||
out = append(out, diff)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value != "" {
|
||||
out = append(out, value)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneStringMap(in map[string]string) map[string]string {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(in))
|
||||
for key, value := range in {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func ctxErr(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
Reference in new issue
Block a user