init: ops-assistant codebase

This commit is contained in:
OpenClaw Agent
2026-03-19 21:23:28 +08:00
commit 81deba4766
94 changed files with 10767 additions and 0 deletions

View File

@@ -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
}

103
internal/core/ai/client.go Normal file
View File

@@ -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
}

View File

@@ -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}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -0,0 +1,97 @@
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
}
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)
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
}

View File

@@ -0,0 +1,26 @@
package ops
import (
"path/filepath"
"ops-assistant/internal/core/registry"
"ops-assistant/internal/core/runbook"
"ops-assistant/internal/module/cf"
"ops-assistant/internal/module/cpa"
"ops-assistant/internal/module/mail"
"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)
r.RegisterModule("cpa", cpaModule.Handle)
r.RegisterModule("cf", cfModule.Handle)
r.RegisterModule("mail", mailModule.Handle)
return NewService(dbPath, baseDir, r)
}

View File

@@ -0,0 +1,60 @@
package ops
import (
"encoding/json"
"errors"
"path/filepath"
"strings"
"ops-assistant/internal/core/runbook"
"ops-assistant/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func decodeInputJSON(raw string, out *map[string]string) error {
if strings.TrimSpace(raw) == "" {
return nil
}
return json.Unmarshal([]byte(raw), out)
}
func RetryJobWithDB(db *gorm.DB, baseDir string, jobID uint) (uint, error) {
if db == nil {
return 0, errors.New("db is nil")
}
var old models.OpsJob
if err := db.First(&old, jobID).Error; err != nil {
return 0, err
}
if strings.TrimSpace(old.Status) != "failed" {
return 0, errors.New("only failed jobs can retry")
}
inputs := map[string]string{}
if strings.TrimSpace(old.InputJSON) != "" {
_ = decodeInputJSON(old.InputJSON, &inputs)
}
meta := runbook.NewMeta()
meta.Target = old.Target
meta.RiskLevel = old.RiskLevel
meta.RequestID = old.RequestID + "-retry"
meta.ConfirmHash = old.ConfirmHash
exec := runbook.NewExecutor(db, filepath.Join(baseDir, "runbooks"))
newID, _, err := exec.RunWithInputsAndMeta(old.Command, old.Runbook, old.Operator, inputs, meta)
if err != nil {
return newID, err
}
return newID, nil
}
func RetryJob(dbPath, baseDir string, jobID uint) (uint, error) {
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return 0, err
}
return RetryJobWithDB(db, baseDir, jobID)
}

View File

@@ -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())
}

View File

@@ -0,0 +1,100 @@
package ops
import (
"fmt"
"strings"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"ops-assistant/internal/core/command"
coremodule "ops-assistant/internal/core/module"
"ops-assistant/internal/core/registry"
)
type Service struct {
dbPath string
baseDir string
registry *registry.Registry
db *gorm.DB
}
func NewService(dbPath, baseDir string, reg *registry.Registry) *Service {
db, _ := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
return &Service{dbPath: dbPath, baseDir: baseDir, registry: reg, db: db}
}
func (s *Service) Handle(userID int64, text string) (bool, string) {
if !strings.HasPrefix(strings.TrimSpace(text), "/") {
return false, ""
}
cmd, _, err := command.ParseWithInputs(text)
if err != nil {
return false, ""
}
// 通用帮助
if cmd.Module == "help" || cmd.Name == "/help" || cmd.Name == "/start" {
return true, s.helpText()
}
if cmd.Module == "ops" && (len(cmd.Args) == 0 || cmd.Args[0] == "help") {
return true, s.helpText()
}
if cmd.Module == "ops" && len(cmd.Args) > 0 && cmd.Args[0] == "modules" {
return true, s.modulesStatusText()
}
if cmd.Module != "" && cmd.Module != "ops" && s.db != nil {
if !coremodule.IsEnabled(s.db, cmd.Module) {
return true, fmt.Sprintf("[ERR_FEATURE_DISABLED] 模块未启用: %s开关: enable_module_%s", cmd.Module, cmd.Module)
}
}
out, handled, err := s.registry.Handle(userID, cmd)
if !handled {
return false, ""
}
if err != nil {
return true, "❌ OPS 执行失败: " + err.Error()
}
return true, out
}
func (s *Service) helpText() string {
lines := []string{
"🛠️ 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",
}
return strings.Join(lines, "\n")
}
func (s *Service) modulesStatusText() string {
mods := s.registry.ListModules()
if len(mods) == 0 {
return "暂无已注册模块"
}
lines := []string{"🧩 模块状态:"}
for _, m := range mods {
enabled := false
if s.db != nil {
enabled = coremodule.IsEnabled(s.db, m)
}
state := "disabled"
if enabled {
state = "enabled"
}
lines = append(lines, fmt.Sprintf("- %s: %s", m, state))
}
lines = append(lines, "\n可用命令/ops modules")
return strings.Join(lines, "\n")
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,6 @@
package ports
type Module interface {
Name() string
CommandPrefix() string
}

View File

@@ -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
}

View File

@@ -0,0 +1,32 @@
package runbook
import (
"context"
"sync"
)
var jobCancelMap sync.Map
func registerJobCancel(jobID uint, cancel context.CancelFunc) {
jobCancelMap.Store(jobID, cancel)
}
func clearJobCancel(jobID uint) {
if v, ok := jobCancelMap.Load(jobID); ok {
if cancel, ok2 := v.(context.CancelFunc); ok2 {
cancel()
}
jobCancelMap.Delete(jobID)
}
}
func CancelJob(jobID uint) bool {
if v, ok := jobCancelMap.Load(jobID); ok {
if cancel, ok2 := v.(context.CancelFunc); ok2 {
cancel()
}
jobCancelMap.Delete(jobID)
return true
}
return false
}

View File

@@ -0,0 +1,387 @@
package runbook
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"ops-assistant/internal/core/ecode"
"ops-assistant/models"
"gorm.io/gorm"
)
type Executor struct {
db *gorm.DB
runbookDir string
}
func NewExecutor(db *gorm.DB, runbookDir string) *Executor {
return &Executor{db: db, runbookDir: runbookDir}
}
func (e *Executor) Run(commandText, runbookName string, operator int64) (uint, string, error) {
return e.RunWithInputsAndMeta(commandText, runbookName, operator, map[string]string{}, NewMeta())
}
func (e *Executor) RunWithInputs(commandText, runbookName string, operator int64, inputs map[string]string) (uint, string, error) {
return e.RunWithInputsAndMeta(commandText, runbookName, operator, inputs, NewMeta())
}
func (e *Executor) RunWithInputsAndMeta(commandText, runbookName string, operator int64, inputs map[string]string, meta RunMeta) (uint, string, error) {
started := time.Now()
inputJSON := "{}"
if b, err := json.Marshal(inputs); err == nil {
inputJSON = string(b)
}
job := models.OpsJob{
Command: commandText,
Runbook: runbookName,
Operator: operator,
Target: strings.TrimSpace(meta.Target),
RiskLevel: strings.TrimSpace(meta.RiskLevel),
RequestID: strings.TrimSpace(meta.RequestID),
ConfirmHash: strings.TrimSpace(meta.ConfirmHash),
InputJSON: inputJSON,
Status: "pending",
StartedAt: started,
}
if job.RiskLevel == "" {
job.RiskLevel = "low"
}
if err := e.db.Create(&job).Error; err != nil {
return 0, "", err
}
release := acquireTargetLock(job.Target)
defer release()
job.Status = "running"
_ = e.db.Save(&job).Error
specPath := filepath.Join(e.runbookDir, runbookName+".yaml")
data, err := os.ReadFile(specPath)
if err != nil {
e.finishJob(&job, "failed", "runbook not found")
return job.ID, "", err
}
spec, err := Parse(data)
if err != nil {
e.finishJob(&job, "failed", "runbook parse failed")
return job.ID, "", err
}
outputs := map[string]string{}
ctx := map[string]string{}
jobCtx, jobCancel := context.WithCancel(context.Background())
registerJobCancel(job.ID, jobCancel)
defer clearJobCancel(job.ID)
for k, v := range inputs {
ctx["inputs."+k] = v
}
if t := strings.TrimSpace(os.Getenv("CPA_MANAGEMENT_BASE")); t != "" {
ctx["env.cpa_management_base"] = t
} else {
var sset models.AppSetting
if err := e.db.Where("key = ?", "cpa_management_base").First(&sset).Error; err == nil {
if strings.TrimSpace(sset.Value) != "" {
ctx["env.cpa_management_base"] = strings.TrimSpace(sset.Value)
}
}
}
if t := strings.TrimSpace(os.Getenv("CPA_MANAGEMENT_TOKEN")); t != "" {
ctx["env.cpa_management_token"] = t
} else {
var sset models.AppSetting
if err := e.db.Where("key = ?", "cpa_management_token").First(&sset).Error; err == nil {
if strings.TrimSpace(sset.Value) != "" {
ctx["env.cpa_management_token"] = strings.TrimSpace(sset.Value)
}
}
}
// Cloudflare settings
if t := strings.TrimSpace(os.Getenv("CF_ACCOUNT_ID")); t != "" {
ctx["env_cf_account_id"] = t
} else {
var sset models.AppSetting
if err := e.db.Where("key = ?", "cf_account_id").First(&sset).Error; err == nil {
if strings.TrimSpace(sset.Value) != "" {
ctx["env_cf_account_id"] = strings.TrimSpace(sset.Value)
}
}
}
if t := strings.TrimSpace(os.Getenv("CF_API_EMAIL")); t != "" {
ctx["env_cf_api_email"] = t
} else {
var sset models.AppSetting
if err := e.db.Where("key = ?", "cf_api_email").First(&sset).Error; err == nil {
if strings.TrimSpace(sset.Value) != "" {
ctx["env_cf_api_email"] = strings.TrimSpace(sset.Value)
}
}
}
if t := strings.TrimSpace(os.Getenv("CF_API_TOKEN")); t != "" {
ctx["env_cf_api_token"] = t
} else {
var sset models.AppSetting
if err := e.db.Where("key = ?", "cf_api_token").First(&sset).Error; err == nil {
if strings.TrimSpace(sset.Value) != "" {
ctx["env_cf_api_token"] = strings.TrimSpace(sset.Value)
}
}
}
// inject input env vars for runbook steps
for k, v := range inputs {
if strings.TrimSpace(v) != "" {
ctx["env.INPUT_"+strings.ToUpper(k)] = v
}
}
for _, st := range spec.Steps {
if isJobCancelled(e.db, job.ID) {
e.finishJob(&job, "cancelled", ecode.Tag(ecode.ErrJobCancelled, "cancelled by user"))
return job.ID, "", fmt.Errorf(ecode.Tag(ecode.ErrJobCancelled, "cancelled by user"))
}
rendered := renderStep(st, ctx)
step := models.OpsJobStep{JobID: job.ID, StepID: rendered.ID, Action: rendered.Action, Status: "running", StartedAt: time.Now()}
_ = e.db.Create(&step).Error
timeout := meta.timeoutOrDefault()
rc, stdout, stderr, runErr := e.execStep(jobCtx, rendered, outputs, timeout)
step.RC = rc
step.StdoutTail = tail(stdout, 1200)
step.StderrTail = tail(stderr, 1200)
step.EndedAt = time.Now()
if runErr != nil || rc != 0 {
step.Status = "failed"
_ = e.db.Save(&step).Error
e.finishJob(&job, "failed", fmt.Sprintf("%s: step=%s failed", ecode.ErrStepFailed, rendered.ID))
if runErr == nil {
runErr = fmt.Errorf("rc=%d", rc)
}
return job.ID, "", fmt.Errorf(ecode.Tag(ecode.ErrStepFailed, fmt.Sprintf("step %s failed: %v", rendered.ID, runErr)))
}
step.Status = "success"
_ = e.db.Save(&step).Error
outputs[rendered.ID] = stdout
ctx["steps."+rendered.ID+".output"] = stdout
}
e.finishJob(&job, "success", "ok")
return job.ID, "ok", nil
}
func (e *Executor) execStep(parent context.Context, st Step, outputs map[string]string, timeout time.Duration) (int, string, string, error) {
switch st.Action {
case "ssh.exec":
target := strings.TrimSpace(fmt.Sprintf("%v", st.With["target"]))
cmdText := strings.TrimSpace(fmt.Sprintf("%v", st.With["command"]))
if target == "" || cmdText == "" {
return 1, "", "missing target/command", fmt.Errorf("missing target/command")
}
resolved := resolveTarget(e.db, target)
if !resolved.Found {
return 1, "", "invalid target", fmt.Errorf("invalid target: %s", target)
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
args := []string{"-p", strconv.Itoa(resolved.Port), resolved.User + "@" + resolved.Host, cmdText}
cmd := exec.CommandContext(ctx, "ssh", args...)
var outb, errb bytes.Buffer
cmd.Stdout = &outb
cmd.Stderr = &errb
err := cmd.Run()
rc := 0
if err != nil {
rc = 1
if ctx.Err() == context.DeadlineExceeded {
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "ssh step timeout"))
}
}
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err
case "shell.exec":
cmdText := strings.TrimSpace(fmt.Sprintf("%v", st.With["command"]))
if cmdText == "" {
return 1, "", "missing command", fmt.Errorf("missing command")
}
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "bash", "-lc", cmdText)
var outb, errb bytes.Buffer
cmd.Stdout = &outb
cmd.Stderr = &errb
err := cmd.Run()
rc := 0
if err != nil {
rc = 1
if ctx.Err() == context.DeadlineExceeded {
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), fmt.Errorf(ecode.Tag(ecode.ErrStepTimeout, "shell step timeout"))
}
}
return rc, strings.TrimSpace(outb.String()), strings.TrimSpace(errb.String()), err
case "assert.json":
sourceStep := strings.TrimSpace(fmt.Sprintf("%v", st.With["source_step"]))
if sourceStep == "" {
return 1, "", "missing source_step", fmt.Errorf("missing source_step")
}
raw, ok := outputs[sourceStep]
if !ok {
return 1, "", "source step output not found", fmt.Errorf("source step output not found: %s", sourceStep)
}
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
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")
}
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)
}
}
return 0, "assert ok", "", nil
case "sleep":
ms := 1000
if v, ok := st.With["ms"]; ok {
switch t := v.(type) {
case int:
ms = t
case int64:
ms = int(t)
case float64:
ms = int(t)
case string:
if n, err := strconv.Atoi(strings.TrimSpace(t)); err == nil {
ms = n
}
}
}
if ms < 0 {
ms = 0
}
time.Sleep(time.Duration(ms) * time.Millisecond)
return 0, fmt.Sprintf("slept %dms", ms), "", nil
default:
return 1, "", "unsupported action", fmt.Errorf("unsupported action: %s", st.Action)
}
}
func renderStep(st Step, ctx map[string]string) Step {
out := st
out.ID = renderString(out.ID, ctx)
out.Action = renderString(out.Action, ctx)
if out.With == nil {
return out
}
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
}
}
out.With = m
return out
}
func renderString(s string, ctx map[string]string) string {
res := s
for k, v := range ctx {
res = strings.ReplaceAll(res, "${"+k+"}", v)
}
return res
}
func parseRequiredPaths(v any) []string {
res := []string{}
switch t := v.(type) {
case []any:
for _, it := range t {
res = append(res, strings.TrimSpace(fmt.Sprintf("%v", it)))
}
case []string:
for _, it := range t {
res = append(res, strings.TrimSpace(it))
}
}
out := make([]string, 0, len(res))
for _, p := range res {
if p != "" {
out = append(out, p)
}
}
return out
}
func lookupPath(root any, path string) (any, bool) {
parts := strings.Split(path, ".")
cur := root
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
return nil, false
}
m, ok := cur.(map[string]any)
if !ok {
return nil, false
}
next, exists := m[part]
if !exists {
return nil, false
}
cur = next
}
return cur, true
}
func (e *Executor) finishJob(job *models.OpsJob, status, summary string) {
job.Status = status
job.Summary = summary
job.EndedAt = time.Now()
_ = e.db.Save(job).Error
}
func isJobCancelled(db *gorm.DB, jobID uint) bool {
var j models.OpsJob
if err := db.Select("status").First(&j, jobID).Error; err != nil {
return false
}
return strings.EqualFold(strings.TrimSpace(j.Status), "cancelled")
}
func tail(s string, max int) string {
s = strings.TrimSpace(s)
if len(s) <= max {
return s
}
return s[len(s)-max:]
}

View File

@@ -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() }
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -0,0 +1,37 @@
package runbook
import (
"strings"
"ops-assistant/models"
"gorm.io/gorm"
)
type ResolvedTarget struct {
Found bool
User string
Host string
Port int
}
func resolveTarget(db *gorm.DB, name string) ResolvedTarget {
trim := strings.TrimSpace(name)
if trim == "" {
return ResolvedTarget{}
}
var t models.OpsTarget
if err := db.Where("name = ? AND enabled = ?", trim, true).First(&t).Error; err != nil {
return ResolvedTarget{}
}
user := strings.TrimSpace(t.User)
host := strings.TrimSpace(t.Host)
port := t.Port
if user == "" || host == "" {
return ResolvedTarget{}
}
if port <= 0 {
port = 22
}
return ResolvedTarget{Found: true, User: user, Host: host, Port: port}
}

View File

@@ -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
}