feat: 模块化命令框架 + runbook 严格断言与查询结果回传
- core/module: 命令模板、门控(feature flag/confirm/dry-run)、ecode 错误标签、ResultFormatter - runbook executor: http.get/http.post/assert.json(expect_success 严格断言)、目标主机管理、任务落库、最终 stdout 直接回传 - cf 模块: /cf dns list 支持域名自动解析 zone_id;dns list/zones/workers list 格式化回传;API success:false 判真失败 - 全部 CF/CPA runbook 接入严格断言,杜绝假阳性 success - 新增 cpa/cfdav/mail/sub 模块与 runbooks;QQ 订阅到期提醒调度 - Web 后台: 模块设置(CF/CPA/cfdav/AI)、OPS 任务、渠道管理、审计页
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
package ai
|
||||
|
||||
type Mode string
|
||||
|
||||
const (
|
||||
ModeOff Mode = "off"
|
||||
ModeSuggest Mode = "suggest"
|
||||
ModeExplain Mode = "explain"
|
||||
)
|
||||
|
||||
type Advisor interface {
|
||||
Suggest(userInput string) (string, error)
|
||||
Explain(result string) (string, error)
|
||||
}
|
||||
|
||||
type NoopAdvisor struct{}
|
||||
|
||||
func (NoopAdvisor) Suggest(userInput string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (NoopAdvisor) Explain(result string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Model string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type chatMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
type chatRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []chatMessage `json:"messages"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
type chatResponse struct {
|
||||
Choices []struct {
|
||||
Message chatMessage `json:"message"`
|
||||
} `json:"choices"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
func (c *Client) Suggest(userInput string) (string, error) {
|
||||
return c.chat(userInput)
|
||||
}
|
||||
|
||||
func (c *Client) Explain(result string) (string, error) {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func commandGuide() string {
|
||||
b, err := os.ReadFile("docs/ai_command_guide.md")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func (c *Client) chat(userInput string) (string, error) {
|
||||
if strings.TrimSpace(c.BaseURL) == "" || strings.TrimSpace(c.APIKey) == "" || strings.TrimSpace(c.Model) == "" {
|
||||
return "", errors.New("ai config missing")
|
||||
}
|
||||
base := strings.TrimRight(c.BaseURL, "/")
|
||||
url := base + "/chat/completions"
|
||||
|
||||
sys := "你是命令翻译器。把用户的自然语言转换成系统支持的标准命令。只输出一行命令,不要解释。若无法确定,输出 FAIL。\n\n可用命令知识库:\n" + commandGuide() + "\n\n规则:严格按命令格式输出。缺少关键参数时输出 FAIL。不要猜测 zone_id/record_id/backup_id。"
|
||||
req := chatRequest{
|
||||
Model: c.Model,
|
||||
Messages: []chatMessage{
|
||||
{Role: "system", Content: sys},
|
||||
{Role: "user", Content: userInput},
|
||||
},
|
||||
Temperature: 0,
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
client := &http.Client{Timeout: c.Timeout}
|
||||
httpReq, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
|
||||
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
if resp.StatusCode == 429 {
|
||||
return "", fmt.Errorf("ai rate limited")
|
||||
}
|
||||
return "", fmt.Errorf("ai http %d", resp.StatusCode)
|
||||
}
|
||||
var out chatResponse
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if out.Error != nil && out.Error.Message != "" {
|
||||
return "", errors.New(out.Error.Message)
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return "", errors.New("empty ai response")
|
||||
}
|
||||
return strings.TrimSpace(out.Choices[0].Message.Content), nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package ai
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ops-assistant/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func LoadClient(db *gorm.DB) *Client {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
get := func(key string) string {
|
||||
var sset models.AppSetting
|
||||
if err := db.Where("key = ?", key).First(&sset).Error; err == nil {
|
||||
return strings.TrimSpace(sset.Value)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if strings.ToLower(get("ai_enabled")) != "true" {
|
||||
return nil
|
||||
}
|
||||
baseURL := get("ai_base_url")
|
||||
apiKey := get("ai_api_key")
|
||||
model := get("ai_model")
|
||||
to := 15
|
||||
if v := get("ai_timeout_seconds"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
to = n
|
||||
}
|
||||
}
|
||||
if baseURL == "" || apiKey == "" || model == "" {
|
||||
return nil
|
||||
}
|
||||
return &Client{BaseURL: baseURL, APIKey: apiKey, Model: model, Timeout: time.Duration(to) * time.Second}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ParsedCommand struct {
|
||||
Raw string
|
||||
Name string
|
||||
Args []string
|
||||
Module string
|
||||
}
|
||||
|
||||
func Parse(raw string) (*ParsedCommand, error) {
|
||||
text := strings.TrimSpace(raw)
|
||||
if text == "" || !strings.HasPrefix(text, "/") {
|
||||
return nil, fmt.Errorf("not a command")
|
||||
}
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) == 0 {
|
||||
return nil, fmt.Errorf("empty command")
|
||||
}
|
||||
|
||||
cmd := &ParsedCommand{Raw: text, Name: parts[0]}
|
||||
if len(parts) > 1 {
|
||||
cmd.Args = parts[1:]
|
||||
}
|
||||
mod := strings.TrimPrefix(cmd.Name, "/")
|
||||
if i := strings.Index(mod, "@"); i > 0 {
|
||||
mod = mod[:i]
|
||||
}
|
||||
if mod != "" {
|
||||
cmd.Module = mod
|
||||
}
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
// ParseWithInputs: 支持 /cf dns list <zone_id> 这种输入参数写入 runbook inputs
|
||||
func ParseWithInputs(raw string) (*ParsedCommand, map[string]string, error) {
|
||||
cmd, err := Parse(raw)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
inputs := map[string]string{}
|
||||
if cmd.Module == "cf" {
|
||||
// /cf dns list <zone_id>
|
||||
if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "list" && len(cmd.Args) >= 3 {
|
||||
inputs["zone_id"] = cmd.Args[2]
|
||||
}
|
||||
// /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied]
|
||||
if len(cmd.Args) >= 2 && cmd.Args[0] == "dns" && cmd.Args[1] == "update" && len(cmd.Args) >= 7 {
|
||||
inputs["zone_id"] = cmd.Args[2]
|
||||
inputs["record_id"] = cmd.Args[3]
|
||||
inputs["type"] = cmd.Args[4]
|
||||
inputs["name"] = cmd.Args[5]
|
||||
inputs["content"] = cmd.Args[6]
|
||||
if len(cmd.Args) >= 8 {
|
||||
inputs["ttl"] = cmd.Args[7]
|
||||
}
|
||||
if len(cmd.Args) >= 9 {
|
||||
inputs["proxied"] = cmd.Args[8]
|
||||
}
|
||||
}
|
||||
}
|
||||
return cmd, inputs, nil
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package ecode
|
||||
|
||||
const (
|
||||
ErrPermissionDenied = "ERR_PERMISSION_DENIED"
|
||||
ErrConfirmRequired = "ERR_CONFIRM_REQUIRED"
|
||||
ErrFeatureDisabled = "ERR_FEATURE_DISABLED"
|
||||
ErrStepFailed = "ERR_STEP_FAILED"
|
||||
ErrJobCancelled = "ERR_JOB_CANCELLED"
|
||||
ErrStepTimeout = "ERR_STEP_TIMEOUT"
|
||||
)
|
||||
|
||||
func Tag(code, msg string) string {
|
||||
if code == "" {
|
||||
return msg
|
||||
}
|
||||
return "[" + code + "] " + msg
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package module
|
||||
|
||||
import "ops-assistant/internal/core/runbook"
|
||||
|
||||
type Gate struct {
|
||||
NeedFlag string
|
||||
RequireConfirm bool
|
||||
ExpectedToken string
|
||||
AllowDryRun bool
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
RunbookName string
|
||||
Inputs map[string]string
|
||||
Meta runbook.RunMeta
|
||||
Gate Gate
|
||||
DryRun bool
|
||||
ConfirmToken string
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ops-assistant/internal/core/ecode"
|
||||
"ops-assistant/internal/core/policy"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
)
|
||||
|
||||
type Runner struct {
|
||||
db *gorm.DB
|
||||
exec *runbook.Executor
|
||||
}
|
||||
|
||||
func NewRunner(db *gorm.DB, exec *runbook.Executor) *Runner {
|
||||
return &Runner{db: db, exec: exec}
|
||||
}
|
||||
|
||||
func (r *Runner) Run(commandText string, operator int64, req Request) (uint, string, error) {
|
||||
if strings.TrimSpace(req.RunbookName) == "" {
|
||||
return 0, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "runbook 不能为空"))
|
||||
}
|
||||
if req.DryRun {
|
||||
if !req.Gate.AllowDryRun {
|
||||
return 0, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, "当前命令不允许 dry-run"))
|
||||
}
|
||||
return 0, "dry-run", nil
|
||||
}
|
||||
if err := policy.CheckGate(r.db, policy.GateRequest{
|
||||
NeedFlag: req.Gate.NeedFlag,
|
||||
RequireConfirm: req.Gate.RequireConfirm,
|
||||
ConfirmToken: req.ConfirmToken,
|
||||
ExpectedToken: req.Gate.ExpectedToken,
|
||||
AllowDryRun: req.Gate.AllowDryRun,
|
||||
DryRun: req.DryRun,
|
||||
}); err != nil {
|
||||
code := ecode.ErrFeatureDisabled
|
||||
if strings.Contains(err.Error(), "confirm") || strings.Contains(err.Error(), "确认") {
|
||||
code = ecode.ErrConfirmRequired
|
||||
}
|
||||
return 0, "", fmt.Errorf(ecode.Tag(code, err.Error()))
|
||||
}
|
||||
return r.exec.RunWithInputsAndMeta(commandText, req.RunbookName, operator, req.Inputs, req.Meta)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ops-assistant/internal/core/policy"
|
||||
)
|
||||
|
||||
func switchFlag(module string) string {
|
||||
module = strings.TrimSpace(strings.ToLower(module))
|
||||
if module == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("enable_module_%s", module)
|
||||
}
|
||||
|
||||
func IsEnabled(db *gorm.DB, module string) bool {
|
||||
k := switchFlag(module)
|
||||
if k == "" {
|
||||
return false
|
||||
}
|
||||
return policy.FlagEnabled(db, k)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package module
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ops-assistant/internal/core/policy"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
)
|
||||
|
||||
type CommandTemplate struct {
|
||||
RunbookName string
|
||||
Gate Gate
|
||||
InputsFn func(text string, parts []string) (map[string]string, error)
|
||||
MetaFn func(userID int64, confirmToken string, inputs map[string]string) runbook.RunMeta
|
||||
DryRunMsg string
|
||||
SuccessMsg func(jobID uint) string
|
||||
// ResultFormatter 可选:查询类命令用最终 stdout 格式化回传内容。
|
||||
// 返回 (消息, true) 表示使用格式化结果;返回 (anything, false) 回退到回执式消息。
|
||||
ResultFormatter func(out string) (string, bool)
|
||||
}
|
||||
|
||||
type CommandSpec struct {
|
||||
Prefixes []string
|
||||
Template CommandTemplate
|
||||
ErrPrefix string
|
||||
ErrHint string
|
||||
}
|
||||
|
||||
func ExecTemplate(runner *Runner, userID int64, raw string, tpl CommandTemplate) (uint, string, error) {
|
||||
dryRun, confirmToken := policy.ParseCommonFlags(raw)
|
||||
parts := strings.Fields(strings.TrimSpace(raw))
|
||||
inputs := map[string]string{}
|
||||
if tpl.InputsFn != nil {
|
||||
out, err := tpl.InputsFn(raw, parts)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
inputs = out
|
||||
}
|
||||
meta := runbook.NewMeta()
|
||||
if tpl.MetaFn != nil {
|
||||
meta = tpl.MetaFn(userID, confirmToken, inputs)
|
||||
}
|
||||
if meta.RequestID == "" {
|
||||
meta.RequestID = fmt.Sprintf("ops-u%d-%d", userID, time.Now().Unix())
|
||||
}
|
||||
req := Request{
|
||||
RunbookName: tpl.RunbookName,
|
||||
Inputs: inputs,
|
||||
Meta: meta,
|
||||
Gate: tpl.Gate,
|
||||
DryRun: dryRun,
|
||||
ConfirmToken: confirmToken,
|
||||
}
|
||||
jobID, out, err := runner.Run(raw, userID, req)
|
||||
if err != nil {
|
||||
return jobID, out, err
|
||||
}
|
||||
if tpl.ResultFormatter != nil && out != "" && out != "dry-run" {
|
||||
if msg, ok := tpl.ResultFormatter(out); ok {
|
||||
return jobID, msg, nil
|
||||
}
|
||||
}
|
||||
return jobID, out, err
|
||||
}
|
||||
|
||||
func FormatDryRunMessage(tpl CommandTemplate) string {
|
||||
if tpl.DryRunMsg != "" {
|
||||
return tpl.DryRunMsg
|
||||
}
|
||||
return fmt.Sprintf("🧪 dry-run: 将执行 %s(未实际执行)", tpl.RunbookName)
|
||||
}
|
||||
|
||||
func FormatSuccessMessage(tpl CommandTemplate, jobID uint) string {
|
||||
if tpl.SuccessMsg != nil {
|
||||
return tpl.SuccessMsg(jobID)
|
||||
}
|
||||
return fmt.Sprintf("✅ %s 已执行,job=%d", tpl.RunbookName, jobID)
|
||||
}
|
||||
|
||||
func MatchAnyPrefix(text string, prefixes []string) bool {
|
||||
text = strings.TrimSpace(text)
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(text, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func MatchCommand(text string, specs []CommandSpec) (CommandSpec, bool) {
|
||||
for _, sp := range specs {
|
||||
if MatchAnyPrefix(text, sp.Prefixes) {
|
||||
return sp, true
|
||||
}
|
||||
}
|
||||
return CommandSpec{}, false
|
||||
}
|
||||
|
||||
func FormatExecError(sp CommandSpec, err error) string {
|
||||
msg := sp.ErrPrefix + err.Error()
|
||||
if sp.ErrHint != "" {
|
||||
msg += "(示例:" + sp.ErrHint + ")"
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package ops
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"ops-assistant/internal/core/registry"
|
||||
"ops-assistant/internal/core/runbook"
|
||||
"ops-assistant/internal/module/cf"
|
||||
"ops-assistant/internal/module/cfdav"
|
||||
"ops-assistant/internal/module/cpa"
|
||||
"ops-assistant/internal/module/mail"
|
||||
"ops-assistant/internal/module/sub"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func BuildDefault(db *gorm.DB, dbPath, baseDir string) *Service {
|
||||
r := registry.New()
|
||||
exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks"))
|
||||
cpaModule := cpa.New(db, exec)
|
||||
cfModule := cf.New(db, exec)
|
||||
mailModule := mail.New(db, exec)
|
||||
cfdavModule := cfdav.New(db)
|
||||
subModule := sub.New(db)
|
||||
|
||||
r.RegisterModule("cpa", cpaModule.Handle)
|
||||
r.RegisterModule("cf", cfModule.Handle)
|
||||
r.RegisterModule("mail", mailModule.Handle)
|
||||
r.RegisterModule("cfdav", cfdavModule.Handle)
|
||||
r.RegisterModule("sub", subModule.Handle)
|
||||
return NewService(dbPath, baseDir, r)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package ops
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
|
||||
"ops-assistant/internal/core/runbook"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RunOnce executes a runbook directly without bot/channel.
|
||||
func RunOnce(dbPath, baseDir, commandText, runbookName string, operator int64, inputs map[string]string) (uint, string, error) {
|
||||
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks"))
|
||||
return exec.RunWithInputsAndMeta(commandText, runbookName, operator, inputs, runbook.NewMeta())
|
||||
}
|
||||
@@ -58,22 +58,14 @@ func (s *Service) Handle(userID int64, text string) (bool, string) {
|
||||
|
||||
func (s *Service) helpText() string {
|
||||
lines := []string{
|
||||
"🛠️ OPS 交互命令:",
|
||||
"🛠️ OPS 帮助(模块入口):",
|
||||
"- /ops modules (查看模块启用状态)",
|
||||
"- /cpa help",
|
||||
"- /cpa status",
|
||||
"- /cpa usage backup",
|
||||
"- /cpa usage restore <backup_id> [--confirm YES_RESTORE] [--dry-run]",
|
||||
"- /cf status (需要 enable_module_cf)",
|
||||
"- /cf zones (需要 enable_module_cf)",
|
||||
"- /cf dns list <zone_id> (需要 enable_module_cf)",
|
||||
"- /cf dns update <zone_id> <record_id> <type> <name> <content> [ttl] [proxied:true|false] (需要 enable_module_cf)",
|
||||
"- /cf dnsadd <name> <content> [on|off] [type] (需要 enable_module_cf)",
|
||||
"- /cf dnsset <record_id> <content> [true] (需要 enable_module_cf)",
|
||||
"- /cf dnsdel <record_id> YES (需要 enable_module_cf)",
|
||||
"- /cf dnsproxy <record_id|name> on|off (需要 enable_module_cf)",
|
||||
"- /cf workers list (需要 enable_module_cf)",
|
||||
"- /mail status (需要 enable_module_mail)",
|
||||
"- /cpa (CPA 使用统计/备份/恢复)",
|
||||
"- /cf (Cloudflare 管理)",
|
||||
"- /mail (邮箱状态)",
|
||||
"- /cfdav (CFDAV 用户管理)",
|
||||
"- /sub (订阅管理)",
|
||||
"提示:发送 /cpa /cf /mail /cfdav /sub 获取各模块完整指令清单。",
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package policy
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ops-assistant/models"
|
||||
)
|
||||
|
||||
type GateRequest struct {
|
||||
NeedFlag string
|
||||
RequireConfirm bool
|
||||
ConfirmToken string
|
||||
ExpectedToken string
|
||||
AllowDryRun bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
func ParseCommonFlags(text string) (dryRun bool, confirmToken string) {
|
||||
parts := strings.Fields(strings.TrimSpace(text))
|
||||
for i := 0; i < len(parts); i++ {
|
||||
if parts[i] == "--dry-run" {
|
||||
dryRun = true
|
||||
}
|
||||
if parts[i] == "--confirm" && i+1 < len(parts) {
|
||||
confirmToken = strings.TrimSpace(parts[i+1])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func FlagEnabled(db *gorm.DB, key string) bool {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return true
|
||||
}
|
||||
var ff models.FeatureFlag
|
||||
if err := db.Where("key = ?", key).First(&ff).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return ff.Enabled
|
||||
}
|
||||
|
||||
func CheckGate(db *gorm.DB, req GateRequest) error {
|
||||
if strings.TrimSpace(req.NeedFlag) != "" && !FlagEnabled(db, req.NeedFlag) {
|
||||
return errors.New("feature flag 未启用: " + req.NeedFlag)
|
||||
}
|
||||
if req.RequireConfirm {
|
||||
if strings.TrimSpace(req.ConfirmToken) == "" {
|
||||
return errors.New("缺少 --confirm <token>")
|
||||
}
|
||||
if strings.TrimSpace(req.ExpectedToken) != "" && strings.TrimSpace(req.ConfirmToken) != strings.TrimSpace(req.ExpectedToken) {
|
||||
return errors.New("确认 token 无效")
|
||||
}
|
||||
}
|
||||
if req.DryRun && !req.AllowDryRun {
|
||||
return errors.New("当前命令不允许 dry-run")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package ports
|
||||
|
||||
type UnifiedMessage struct {
|
||||
Channel string
|
||||
OperatorID int64
|
||||
Text string
|
||||
RawID string
|
||||
}
|
||||
|
||||
type ChannelAdapter interface {
|
||||
Name() string
|
||||
Normalize(any) (*UnifiedMessage, error)
|
||||
Reply(targetID string, text string) error
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package ports
|
||||
|
||||
type Module interface {
|
||||
Name() string
|
||||
CommandPrefix() string
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"ops-assistant/internal/core/command"
|
||||
)
|
||||
|
||||
type Handler func(userID int64, cmd *command.ParsedCommand) (string, error)
|
||||
|
||||
type Registry struct {
|
||||
handlers map[string]Handler
|
||||
moduleHandlers map[string]Handler
|
||||
}
|
||||
|
||||
func New() *Registry {
|
||||
return &Registry{handlers: map[string]Handler{}, moduleHandlers: map[string]Handler{}}
|
||||
}
|
||||
|
||||
func (r *Registry) Register(name string, h Handler) {
|
||||
r.handlers[name] = h
|
||||
}
|
||||
|
||||
func (r *Registry) RegisterModule(module string, h Handler) {
|
||||
r.moduleHandlers[module] = h
|
||||
}
|
||||
|
||||
func (r *Registry) Handle(userID int64, cmd *command.ParsedCommand) (string, bool, error) {
|
||||
if h, ok := r.handlers[cmd.Name]; ok {
|
||||
out, err := h(userID, cmd)
|
||||
return out, true, err
|
||||
}
|
||||
if h, ok := r.moduleHandlers[cmd.Module]; ok {
|
||||
out, err := h(userID, cmd)
|
||||
return out, true, err
|
||||
}
|
||||
return "", false, nil
|
||||
}
|
||||
|
||||
func (r *Registry) ListModules() []string {
|
||||
mods := make([]string, 0, len(r.moduleHandlers))
|
||||
for m := range r.moduleHandlers {
|
||||
mods = append(mods, m)
|
||||
}
|
||||
sort.Strings(mods)
|
||||
return mods
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package runbook
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -80,6 +85,7 @@ func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operato
|
||||
|
||||
outputs := map[string]string{}
|
||||
ctx := map[string]string{}
|
||||
dataOut := "ok"
|
||||
|
||||
jobCtx, jobCancel := context.WithCancel(context.Background())
|
||||
registerJobCancel(job.ID, jobCancel)
|
||||
@@ -174,10 +180,13 @@ func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operato
|
||||
_ = e.db.Save(&step).Error
|
||||
outputs[rendered.ID] = stdout
|
||||
ctx["steps."+rendered.ID+".output"] = stdout
|
||||
if strings.TrimSpace(stdout) != "" && rendered.Action != "assert.json" {
|
||||
dataOut = stdout
|
||||
}
|
||||
}
|
||||
|
||||
e.finishJob(&job, "success", "ok")
|
||||
return job.ID, "ok", nil
|
||||
return job.ID, dataOut, nil
|
||||
}
|
||||
|
||||
func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]string, timeout time.Duration) (int, string, string, error) {
|
||||
@@ -230,6 +239,125 @@ func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]
|
||||
}
|
||||
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err
|
||||
|
||||
case "http.get":
|
||||
url := strings.TrimSpace(fmt.Sprintf("%v", st.With["url"]))
|
||||
if url == "" {
|
||||
return 1, "", "missing url", fmt.Errorf("missing url")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
applyHTTPHeaders(req, st.With["headers"])
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return 1, "", "http step timeout", fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "http step timeout"))
|
||||
}
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return 1, strings.TrimSpace(string(body)), resp.Status, fmt.Errorf("http status: %d", resp.StatusCode)
|
||||
}
|
||||
return 0, strings.TrimSpace(string(body)), "", nil
|
||||
|
||||
case "http.post":
|
||||
url := strings.TrimSpace(fmt.Sprintf("%v", st.With["url"]))
|
||||
if url == "" {
|
||||
return 1, "", "missing url", fmt.Errorf("missing url")
|
||||
}
|
||||
bodyStr := ""
|
||||
if b, ok := st.With["body"]; ok {
|
||||
switch t := b.(type) {
|
||||
case string:
|
||||
bodyStr = t
|
||||
default:
|
||||
if jb, err := json.Marshal(t); err == nil {
|
||||
bodyStr = string(jb)
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, strings.NewReader(bodyStr))
|
||||
if err != nil {
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
hasCT := applyHTTPHeaders(req, st.With["headers"])
|
||||
if bodyStr != "" && !hasCT {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return 1, "", "http step timeout", fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "http step timeout"))
|
||||
}
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return 1, strings.TrimSpace(string(respBody)), resp.Status, fmt.Errorf("http status: %d", resp.StatusCode)
|
||||
}
|
||||
return 0, strings.TrimSpace(string(respBody)), "", nil
|
||||
|
||||
case "file.upload":
|
||||
source := strings.TrimSpace(fmt.Sprintf("%v", st.With["source"]))
|
||||
stagingDir := strings.TrimSpace(fmt.Sprintf("%v", st.With["staging_dir"]))
|
||||
filename := strings.TrimSpace(fmt.Sprintf("%v", st.With["filename"]))
|
||||
if source == "" {
|
||||
return 1, "", "missing source", fmt.Errorf("missing source")
|
||||
}
|
||||
if stagingDir == "" {
|
||||
stagingDir = "/tmp/ops-staging"
|
||||
}
|
||||
if filename == "" {
|
||||
filename = filepath.Base(source)
|
||||
}
|
||||
if err := os.MkdirAll(stagingDir, 0o755); err != nil {
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
dest := filepath.Join(stagingDir, filepath.Base(filename))
|
||||
if err := copyFile(source, dest); err != nil {
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
return 0, dest, "", nil
|
||||
|
||||
case "file.extract":
|
||||
archivePath := strings.TrimSpace(fmt.Sprintf("%v", st.With["archive"]))
|
||||
toDir := strings.TrimSpace(fmt.Sprintf("%v", st.With["to_dir"]))
|
||||
if archivePath == "" {
|
||||
return 1, "", "missing archive", fmt.Errorf("missing archive")
|
||||
}
|
||||
if toDir == "" {
|
||||
toDir = "/tmp/ops-staging/extracted"
|
||||
}
|
||||
if err := os.MkdirAll(toDir, 0o755); err != nil {
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
lower := strings.ToLower(archivePath)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".zip"):
|
||||
if err := extractZip(archivePath, toDir); err != nil {
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"):
|
||||
if err := extractTarGz(archivePath, toDir); err != nil {
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
case strings.HasSuffix(lower, ".tar"):
|
||||
if err := extractTar(archivePath, toDir); err != nil {
|
||||
return 1, "", err.Error(), err
|
||||
}
|
||||
default:
|
||||
return 1, "", "unsupported archive format", fmt.Errorf("unsupported archive format: %s", archivePath)
|
||||
}
|
||||
return 0, toDir, "", nil
|
||||
|
||||
case "assert.json":
|
||||
sourceStep := strings.TrimSpace(fmt.Sprintf("%v", st.With["source_step"]))
|
||||
if sourceStep == "" {
|
||||
@@ -245,15 +373,27 @@ func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]
|
||||
return 1, "", "invalid json", err
|
||||
}
|
||||
|
||||
rules := parseRequiredPaths(st.With["required_paths"])
|
||||
if len(rules) == 0 {
|
||||
return 1, "", "required_paths empty", fmt.Errorf("required_paths empty")
|
||||
expectSuccess := parseExpectSuccess(st.With["expect_success"])
|
||||
if expectSuccess {
|
||||
v, ok := lookupPath(payload, "success")
|
||||
if !ok {
|
||||
return 1, "", "json path not found: success", fmt.Errorf("json path not found: success")
|
||||
}
|
||||
if !truthyValue(v) {
|
||||
errSummary := summarizeJSONErrors(payload)
|
||||
return 1, "", "api success=false: " + errSummary, fmt.Errorf("api success=false: %s", errSummary)
|
||||
}
|
||||
}
|
||||
|
||||
rules := parseRequiredPaths(st.With["required_paths"])
|
||||
for _, p := range rules {
|
||||
if _, ok := lookupPath(payload, p); !ok {
|
||||
return 1, "", "json path not found: " + p, fmt.Errorf("json path not found: %s", p)
|
||||
}
|
||||
}
|
||||
if len(rules) == 0 && !expectSuccess {
|
||||
return 1, "", "required_paths empty", fmt.Errorf("required_paths empty")
|
||||
}
|
||||
return 0, "assert ok", "", nil
|
||||
|
||||
case "sleep":
|
||||
@@ -292,27 +432,33 @@ func renderStep(st Step, ctx map[string]string) Step {
|
||||
}
|
||||
m := make(map[string]any, len(out.With))
|
||||
for k, v := range out.With {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
m[k] = renderString(t, ctx)
|
||||
case []any:
|
||||
arr := make([]any, 0, len(t))
|
||||
for _, it := range t {
|
||||
if s, ok := it.(string); ok {
|
||||
arr = append(arr, renderString(s, ctx))
|
||||
} else {
|
||||
arr = append(arr, it)
|
||||
}
|
||||
}
|
||||
m[k] = arr
|
||||
default:
|
||||
m[k] = v
|
||||
}
|
||||
m[k] = renderAny(v, ctx)
|
||||
}
|
||||
out.With = m
|
||||
return out
|
||||
}
|
||||
|
||||
func renderAny(v any, ctx map[string]string) any {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return renderString(t, ctx)
|
||||
case []any:
|
||||
arr := make([]any, 0, len(t))
|
||||
for _, it := range t {
|
||||
arr = append(arr, renderAny(it, ctx))
|
||||
}
|
||||
return arr
|
||||
case map[string]any:
|
||||
m := make(map[string]any, len(t))
|
||||
for k, v := range t {
|
||||
m[k] = renderAny(v, ctx)
|
||||
}
|
||||
return m
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
func renderString(s string, ctx map[string]string) string {
|
||||
res := s
|
||||
for k, v := range ctx {
|
||||
@@ -321,6 +467,158 @@ func renderString(s string, ctx map[string]string) string {
|
||||
return res
|
||||
}
|
||||
|
||||
func applyHTTPHeaders(req *http.Request, raw any) bool {
|
||||
hasContentType := false
|
||||
m, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return hasContentType
|
||||
}
|
||||
for k, v := range m {
|
||||
key := strings.TrimSpace(k)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
val := strings.TrimSpace(fmt.Sprintf("%v", v))
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
req.Header.Set(key, val)
|
||||
if strings.EqualFold(key, "Content-Type") {
|
||||
hasContentType = true
|
||||
}
|
||||
}
|
||||
return hasContentType
|
||||
}
|
||||
|
||||
func copyFile(src, dst string) error {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer in.Close()
|
||||
fi, err := in.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !fi.Mode().IsRegular() {
|
||||
return fmt.Errorf("source is not regular file")
|
||||
}
|
||||
out, err := os.Create(dst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return err
|
||||
}
|
||||
return out.Close()
|
||||
}
|
||||
|
||||
func extractZip(src, dst string) error {
|
||||
zr, err := zip.OpenReader(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer zr.Close()
|
||||
for _, f := range zr.File {
|
||||
clean := filepath.Clean(f.Name)
|
||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||
return fmt.Errorf("invalid zip entry path: %s", f.Name)
|
||||
}
|
||||
target := filepath.Join(dst, clean)
|
||||
if f.FileInfo().IsDir() {
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
rc.Close()
|
||||
return err
|
||||
}
|
||||
_, cpErr := io.Copy(out, rc)
|
||||
rc.Close()
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if cpErr != nil {
|
||||
return cpErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractTar(src, dst string) error {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return untar(f, dst)
|
||||
}
|
||||
|
||||
func extractTarGz(src, dst string) error {
|
||||
f, err := os.Open(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
gz, err := gzip.NewReader(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer gz.Close()
|
||||
return untar(gz, dst)
|
||||
}
|
||||
|
||||
func untar(r io.Reader, dst string) error {
|
||||
tr := tar.NewReader(r)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
clean := filepath.Clean(hdr.Name)
|
||||
if strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
|
||||
return fmt.Errorf("invalid tar entry path: %s", hdr.Name)
|
||||
}
|
||||
target := filepath.Join(dst, clean)
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg, tar.TypeRegA:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.Create(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(out, tr); err != nil {
|
||||
out.Close()
|
||||
return err
|
||||
}
|
||||
if err := out.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseRequiredPaths(v any) []string {
|
||||
res := []string{}
|
||||
switch t := v.(type) {
|
||||
@@ -363,6 +661,51 @@ func lookupPath(root any, path string) (any, bool) {
|
||||
return cur, true
|
||||
}
|
||||
|
||||
func parseExpectSuccess(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(t), "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func truthyValue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case bool:
|
||||
return t
|
||||
case string:
|
||||
return strings.EqualFold(strings.TrimSpace(t), "true")
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeJSONErrors 从 Cloudflare 风格的 {"success":false,"errors":[...]} 中提取可读错误摘要
|
||||
func summarizeJSONErrors(payload any) string {
|
||||
msgs := []string{}
|
||||
if m, ok := payload.(map[string]any); ok {
|
||||
if errs, ok := m["errors"].([]any); ok {
|
||||
for _, e := range errs {
|
||||
switch ev := e.(type) {
|
||||
case map[string]any:
|
||||
code := fmt.Sprintf("%v", ev["code"])
|
||||
msg := fmt.Sprintf("%v", ev["message"])
|
||||
msgs = append(msgs, code+" "+msg)
|
||||
default:
|
||||
msgs = append(msgs, fmt.Sprintf("%v", ev))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return "unknown error"
|
||||
}
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
func (e *Executor) finishJob(job *models.OpsJob, status, summary string) {
|
||||
job.Status = status
|
||||
job.Summary = summary
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package runbook
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
var globalTargetLocks sync.Map
|
||||
|
||||
type targetLock struct {
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func acquireTargetLock(target string) func() {
|
||||
if target == "" {
|
||||
return func() {}
|
||||
}
|
||||
v, _ := globalTargetLocks.LoadOrStore(target, &targetLock{})
|
||||
lk := v.(*targetLock)
|
||||
lk.mu.Lock()
|
||||
return func() { lk.mu.Unlock() }
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package runbook
|
||||
|
||||
import "time"
|
||||
|
||||
type RunMeta struct {
|
||||
Target string
|
||||
RiskLevel string
|
||||
RequestID string
|
||||
ConfirmHash string
|
||||
StepTimeoutMs int
|
||||
}
|
||||
|
||||
func NewMeta() RunMeta {
|
||||
return RunMeta{RiskLevel: "low"}
|
||||
}
|
||||
|
||||
func (m RunMeta) timeoutOrDefault() time.Duration {
|
||||
ms := m.StepTimeoutMs
|
||||
if ms <= 0 {
|
||||
ms = 45000
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package runbook
|
||||
|
||||
import (
|
||||
"ops-assistant/models"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SeedDefaultTargets(db *gorm.DB) error {
|
||||
defaults := []models.OpsTarget{
|
||||
{Name: "hwsg", Host: "10.2.3.11", Port: 22, User: "root", Enabled: true},
|
||||
{Name: "wjynl", Host: "66.235.105.208", Port: 22, User: "root", Enabled: true},
|
||||
}
|
||||
for _, t := range defaults {
|
||||
if err := db.Where("name = ?", t.Name).FirstOrCreate(&t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -35,3 +35,8 @@ func resolveTarget(db *gorm.DB, name string) ResolvedTarget {
|
||||
}
|
||||
return ResolvedTarget{Found: true, User: user, Host: host, Port: port}
|
||||
}
|
||||
|
||||
// ResolveTargetForModule 供其他包(如 cf 模块格式化器)复用目标解析
|
||||
func ResolveTargetForModule(db *gorm.DB, name string) ResolvedTarget {
|
||||
return resolveTarget(db, name)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package runbook
|
||||
|
||||
import "gopkg.in/yaml.v3"
|
||||
|
||||
type Spec struct {
|
||||
Version int `yaml:"version"`
|
||||
Name string `yaml:"name"`
|
||||
Steps []Step `yaml:"steps"`
|
||||
}
|
||||
|
||||
type Step struct {
|
||||
ID string `yaml:"id"`
|
||||
Action string `yaml:"action"`
|
||||
OnFail string `yaml:"on_fail"`
|
||||
With map[string]any `yaml:"with"`
|
||||
}
|
||||
|
||||
func Parse(data []byte) (*Spec, error) {
|
||||
var s Spec
|
||||
if err := yaml.Unmarshal(data, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &s, nil
|
||||
}
|
||||
Reference in New Issue
Block a user