Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ec5931cd42 | |||
| 73a829a4e9 | |||
| 36f11fa846 |
42
cmd/main.go
42
cmd/main.go
@@ -9,15 +9,17 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"xiaji-go/config"
|
"ops-assistant/config"
|
||||||
"xiaji-go/internal/bot"
|
"ops-assistant/internal/bot"
|
||||||
"xiaji-go/internal/channel"
|
"ops-assistant/internal/channel"
|
||||||
"xiaji-go/internal/feishu"
|
"ops-assistant/internal/core/ops"
|
||||||
"xiaji-go/internal/qq"
|
"ops-assistant/internal/core/runbook"
|
||||||
"xiaji-go/internal/service"
|
"ops-assistant/internal/feishu"
|
||||||
"xiaji-go/internal/web"
|
"ops-assistant/internal/qq"
|
||||||
"xiaji-go/models"
|
"ops-assistant/internal/service"
|
||||||
"xiaji-go/version"
|
"ops-assistant/internal/web"
|
||||||
|
"ops-assistant/models"
|
||||||
|
"ops-assistant/version"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/driver/sqlite"
|
"gorm.io/driver/sqlite"
|
||||||
@@ -55,6 +57,10 @@ func main() {
|
|||||||
log.Fatalf("初始化渠道密钥加密失败: %v", err)
|
log.Fatalf("初始化渠道密钥加密失败: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.IsWeakPassword(cfg.Admin.Password) {
|
||||||
|
log.Printf("⚠️ admin 密码过弱或为默认值,请尽快修改")
|
||||||
|
}
|
||||||
|
|
||||||
// DB 渠道配置覆盖 YAML 配置
|
// DB 渠道配置覆盖 YAML 配置
|
||||||
if err := channel.ApplyChannelConfig(db, cfg); err != nil {
|
if err := channel.ApplyChannelConfig(db, cfg); err != nil {
|
||||||
log.Printf("⚠️ 渠道配置加载失败,继续使用 YAML: %v", err)
|
log.Printf("⚠️ 渠道配置加载失败,继续使用 YAML: %v", err)
|
||||||
@@ -63,11 +69,17 @@ func main() {
|
|||||||
finance := service.NewFinanceService(db)
|
finance := service.NewFinanceService(db)
|
||||||
defer finance.Close()
|
defer finance.Close()
|
||||||
|
|
||||||
|
if err := runbook.SeedDefaultTargets(db); err != nil {
|
||||||
|
log.Printf("⚠️ 初始化ops targets失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opsSvc := ops.BuildDefault(db, cfg.Database.Path, ".")
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if cfg.Telegram.Enabled {
|
if cfg.Telegram.Enabled {
|
||||||
tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance)
|
tgBot, err := bot.NewTGBot(db, cfg.Telegram.Token, finance, opsSvc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("⚠️ TG Bot 启动失败: %v", err)
|
log.Printf("⚠️ TG Bot 启动失败: %v", err)
|
||||||
} else {
|
} else {
|
||||||
@@ -76,7 +88,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if cfg.QQBot.Enabled {
|
if cfg.QQBot.Enabled {
|
||||||
qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance)
|
qqBot := qq.NewQQBot(db, cfg.QQBot.AppID, cfg.QQBot.Secret, finance, opsSvc)
|
||||||
go qqBot.Start(ctx)
|
go qqBot.Start(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,11 +103,11 @@ func main() {
|
|||||||
return fmt.Sprintf("reload ok: tg=%v qq=%v feishu=%v", cfg.Telegram.Enabled, cfg.QQBot.Enabled, cfg.Feishu.Enabled), nil
|
return fmt.Sprintf("reload ok: tg=%v qq=%v feishu=%v", cfg.Telegram.Enabled, cfg.QQBot.Enabled, cfg.Feishu.Enabled), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
webServer := web.NewWebServer(db, finance, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn)
|
webServer := web.NewWebServer(db, cfg.Database.Path, ".", finance, cfg.Server.Port, cfg.Admin.Username, cfg.Admin.Password, cfg.Server.Key, reloadFn)
|
||||||
webServer.RegisterRoutes(engine)
|
webServer.RegisterRoutes(engine)
|
||||||
|
|
||||||
if cfg.Feishu.Enabled {
|
if cfg.Feishu.Enabled {
|
||||||
fsBot := feishu.NewBot(db, finance, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey)
|
fsBot := feishu.NewBot(db, finance, opsSvc, cfg.Feishu.AppID, cfg.Feishu.AppSecret, cfg.Feishu.VerificationToken, cfg.Feishu.EncryptKey)
|
||||||
fsBot.RegisterRoutes(engine)
|
fsBot.RegisterRoutes(engine)
|
||||||
go fsBot.Start(ctx)
|
go fsBot.Start(ctx)
|
||||||
}
|
}
|
||||||
@@ -108,7 +120,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Println("🦞 Xiaji-Go 已全面启动")
|
log.Println("🛠️ Ops-Assistant 已全面启动")
|
||||||
sig := make(chan os.Signal, 1)
|
sig := make(chan os.Signal, 1)
|
||||||
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
|
||||||
<-sig
|
<-sig
|
||||||
@@ -122,5 +134,5 @@ func main() {
|
|||||||
sqlDB.Close()
|
sqlDB.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("👋 Xiaji-Go 已关闭")
|
log.Println("👋 Ops-Assistant 已关闭")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
@@ -31,6 +32,13 @@ type Config struct {
|
|||||||
VerificationToken string `yaml:"verification_token"`
|
VerificationToken string `yaml:"verification_token"`
|
||||||
EncryptKey string `yaml:"encrypt_key"`
|
EncryptKey string `yaml:"encrypt_key"`
|
||||||
} `yaml:"feishu"`
|
} `yaml:"feishu"`
|
||||||
|
AI struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
BaseURL string `yaml:"base_url"`
|
||||||
|
APIKey string `yaml:"api_key"`
|
||||||
|
Model string `yaml:"model"`
|
||||||
|
TimeoutSeconds int `yaml:"timeout_seconds"`
|
||||||
|
} `yaml:"ai"`
|
||||||
Admin struct {
|
Admin struct {
|
||||||
Username string `yaml:"username"`
|
Username string `yaml:"username"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
@@ -57,6 +65,24 @@ func LoadConfig(path string) (*Config, error) {
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsWeakPassword(pw string) bool {
|
||||||
|
p := strings.TrimSpace(pw)
|
||||||
|
if p == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
weak := map[string]bool{
|
||||||
|
"admin123": true,
|
||||||
|
"your_password": true,
|
||||||
|
"CHANGE_ME": true,
|
||||||
|
"change_me": true,
|
||||||
|
"password": true,
|
||||||
|
"123456": true,
|
||||||
|
"12345678": true,
|
||||||
|
"qwerty": true,
|
||||||
|
}
|
||||||
|
return weak[p]
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Config) Validate() error {
|
func (c *Config) Validate() error {
|
||||||
if c.Database.Path == "" {
|
if c.Database.Path == "" {
|
||||||
return fmt.Errorf("database.path 不能为空")
|
return fmt.Errorf("database.path 不能为空")
|
||||||
@@ -83,5 +109,13 @@ func (c *Config) Validate() error {
|
|||||||
return fmt.Errorf("feishu 已启用但 app_id 或 app_secret 为空")
|
return fmt.Errorf("feishu 已启用但 app_id 或 app_secret 为空")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if c.AI.Enabled {
|
||||||
|
if c.AI.BaseURL == "" || c.AI.APIKey == "" || c.AI.Model == "" {
|
||||||
|
return fmt.Errorf("ai 已启用但 base_url/api_key/model 为空")
|
||||||
|
}
|
||||||
|
if c.AI.TimeoutSeconds <= 0 {
|
||||||
|
c.AI.TimeoutSeconds = 15
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/aes"
|
"crypto/aes"
|
||||||
"crypto/cipher"
|
"crypto/cipher"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
@@ -15,9 +16,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"xiaji-go/config"
|
"ops-assistant/config"
|
||||||
"xiaji-go/models"
|
"ops-assistant/models"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -29,27 +31,44 @@ type UnifiedMessage struct {
|
|||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var secretCipher *cipherContext
|
const (
|
||||||
|
encPrefixV1 = "enc:v1:"
|
||||||
|
encPrefixV2 = "enc:v2:"
|
||||||
|
)
|
||||||
|
|
||||||
|
var secretCipherV1 *cipherContext
|
||||||
|
var secretCipherV2 *cipherContext
|
||||||
|
|
||||||
type cipherContext struct {
|
type cipherContext struct {
|
||||||
aead cipher.AEAD
|
aead cipher.AEAD
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitSecretCipher(key string) error {
|
func InitSecretCipher(key string) error {
|
||||||
k := deriveKey32(key)
|
k1 := deriveKey32Legacy(key)
|
||||||
block, err := aes.NewCipher(k)
|
block1, err := aes.NewCipher(k1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
aead, err := cipher.NewGCM(block)
|
aead1, err := cipher.NewGCM(block1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
secretCipher = &cipherContext{aead: aead}
|
secretCipherV1 = &cipherContext{aead: aead1}
|
||||||
|
|
||||||
|
k2 := deriveKey32V2(key)
|
||||||
|
block2, err := aes.NewCipher(k2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
aead2, err := cipher.NewGCM(block2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
secretCipherV2 = &cipherContext{aead: aead2}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deriveKey32(s string) []byte {
|
func deriveKey32Legacy(s string) []byte {
|
||||||
b := []byte(s)
|
b := []byte(s)
|
||||||
out := make([]byte, 32)
|
out := make([]byte, 32)
|
||||||
if len(b) >= 32 {
|
if len(b) >= 32 {
|
||||||
@@ -63,37 +82,66 @@ func deriveKey32(s string) []byte {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deriveKey32V2(s string) []byte {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return make([]byte, 32)
|
||||||
|
}
|
||||||
|
// PBKDF2 for deterministic 32-byte key derivation
|
||||||
|
return pbkdf2.Key([]byte(s), []byte("ops-assistant-v1"), 200000, 32, sha256.New)
|
||||||
|
}
|
||||||
|
|
||||||
func encryptString(plain string) (string, error) {
|
func encryptString(plain string) (string, error) {
|
||||||
if secretCipher == nil {
|
if secretCipherV2 == nil {
|
||||||
return plain, errors.New("cipher not initialized")
|
return plain, errors.New("cipher not initialized")
|
||||||
}
|
}
|
||||||
nonce := make([]byte, secretCipher.aead.NonceSize())
|
nonce := make([]byte, secretCipherV2.aead.NonceSize())
|
||||||
if _, err := rand.Read(nonce); err != nil {
|
if _, err := rand.Read(nonce); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
ciphertext := secretCipher.aead.Seal(nil, nonce, []byte(plain), nil)
|
ciphertext := secretCipherV2.aead.Seal(nil, nonce, []byte(plain), nil)
|
||||||
buf := append(nonce, ciphertext...)
|
buf := append(nonce, ciphertext...)
|
||||||
return "enc:v1:" + base64.StdEncoding.EncodeToString(buf), nil
|
return encPrefixV2 + base64.StdEncoding.EncodeToString(buf), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decryptString(raw string) (string, error) {
|
func decryptString(raw string) (string, error) {
|
||||||
if !strings.HasPrefix(raw, "enc:v1:") {
|
if !strings.HasPrefix(raw, encPrefixV1) && !strings.HasPrefix(raw, encPrefixV2) {
|
||||||
return raw, nil
|
return raw, nil
|
||||||
}
|
}
|
||||||
if secretCipher == nil {
|
if strings.HasPrefix(raw, encPrefixV2) {
|
||||||
|
if secretCipherV2 == nil {
|
||||||
|
return "", errors.New("cipher not initialized")
|
||||||
|
}
|
||||||
|
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV2))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
ns := secretCipherV2.aead.NonceSize()
|
||||||
|
if len(data) <= ns {
|
||||||
|
return "", errors.New("invalid ciphertext")
|
||||||
|
}
|
||||||
|
nonce := data[:ns]
|
||||||
|
ct := data[ns:]
|
||||||
|
pt, err := secretCipherV2.aead.Open(nil, nonce, ct, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(pt), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if secretCipherV1 == nil {
|
||||||
return "", errors.New("cipher not initialized")
|
return "", errors.New("cipher not initialized")
|
||||||
}
|
}
|
||||||
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, "enc:v1:"))
|
data, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(raw, encPrefixV1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
ns := secretCipher.aead.NonceSize()
|
ns := secretCipherV1.aead.NonceSize()
|
||||||
if len(data) <= ns {
|
if len(data) <= ns {
|
||||||
return "", errors.New("invalid ciphertext")
|
return "", errors.New("invalid ciphertext")
|
||||||
}
|
}
|
||||||
nonce := data[:ns]
|
nonce := data[:ns]
|
||||||
ct := data[ns:]
|
ct := data[ns:]
|
||||||
pt, err := secretCipher.aead.Open(nil, nonce, ct, nil)
|
pt, err := secretCipherV1.aead.Open(nil, nonce, ct, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -119,10 +167,10 @@ func EncryptSecretJSON(raw string) string {
|
|||||||
if strings.TrimSpace(raw) == "" {
|
if strings.TrimSpace(raw) == "" {
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
if strings.HasPrefix(raw, "enc:v1:") {
|
if strings.HasPrefix(raw, encPrefixV1) || strings.HasPrefix(raw, encPrefixV2) {
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
if secretCipher == nil {
|
if secretCipherV2 == nil {
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
enc, err := encryptString(raw)
|
enc, err := encryptString(raw)
|
||||||
|
|||||||
60
internal/core/ops/retry.go
Normal file
60
internal/core/ops/retry.go
Normal 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)
|
||||||
|
}
|
||||||
32
internal/core/runbook/cancel.go
Normal file
32
internal/core/runbook/cancel.go
Normal 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
|
||||||
|
}
|
||||||
387
internal/core/runbook/executor.go
Normal file
387
internal/core/runbook/executor.go
Normal 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:]
|
||||||
|
}
|
||||||
37
internal/core/runbook/targets.go
Normal file
37
internal/core/runbook/targets.go
Normal 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}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
38
runbooks/cpa_usage_backup.yaml
Normal file
38
runbooks/cpa_usage_backup.yaml
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
version: 1
|
||||||
|
name: cpa_usage_backup
|
||||||
|
description: 实时导出 usage 并打包备份(公网管理接口)
|
||||||
|
inputs: []
|
||||||
|
steps:
|
||||||
|
- id: export_and_package
|
||||||
|
action: shell.exec
|
||||||
|
on_fail: stop
|
||||||
|
with:
|
||||||
|
command: |
|
||||||
|
CPA_TOKEN=${env.cpa_management_token}
|
||||||
|
CPA_BASE=https://cpa.pao.xx.kg/v0/management
|
||||||
|
ts=$(date +%F_%H%M%S)
|
||||||
|
out=/root/cliproxyapi/usage_export_${ts}.json
|
||||||
|
curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage/export -o ${out}
|
||||||
|
|
||||||
|
echo ${out}
|
||||||
|
|
||||||
|
latest=$(ls -1t /root/cliproxyapi/usage_export_*.json | head -n 1)
|
||||||
|
ts=$(date +%Y-%m-%d_%H%M%S)
|
||||||
|
out=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.tar.gz
|
||||||
|
meta=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.meta.txt
|
||||||
|
mkdir -p /root/backups/cpa-runtime-daily
|
||||||
|
tar -czf ${out} ${latest}
|
||||||
|
sha=$(sha256sum ${out} | awk '{print $1}')
|
||||||
|
size=$(du -h ${out} | awk '{print $1}')
|
||||||
|
req=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_requests', data.get('total_requests','unknown')))" )
|
||||||
|
tok=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_tokens', data.get('total_tokens','unknown')))" )
|
||||||
|
{
|
||||||
|
echo "time=$(date '+%F %T %z')"
|
||||||
|
echo "source=${latest}"
|
||||||
|
echo "backup=${out}"
|
||||||
|
echo "sha256=${sha}"
|
||||||
|
echo "size=${size}"
|
||||||
|
echo "total_requests=${req}"
|
||||||
|
echo "total_tokens=${tok}"
|
||||||
|
} > ${meta}
|
||||||
|
cat ${meta}
|
||||||
112
runbooks/cpa_usage_restore.yaml
Normal file
112
runbooks/cpa_usage_restore.yaml
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
version: 1
|
||||||
|
name: cpa_usage_restore
|
||||||
|
description: 从备份包恢复 usage(公网管理接口,双重校验)
|
||||||
|
inputs:
|
||||||
|
- backup_id
|
||||||
|
steps:
|
||||||
|
- id: pre_backup
|
||||||
|
action: shell.exec
|
||||||
|
on_fail: stop
|
||||||
|
with:
|
||||||
|
command: |
|
||||||
|
CPA_TOKEN=${env.cpa_management_token}
|
||||||
|
CPA_BASE=https://cpa.pao.xx.kg/v0/management
|
||||||
|
ts=$(date +%F_%H%M%S)
|
||||||
|
out=/root/cliproxyapi/usage_export_${ts}.json
|
||||||
|
curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage/export -o ${out}
|
||||||
|
|
||||||
|
echo ${out}
|
||||||
|
|
||||||
|
latest=$(ls -1t /root/cliproxyapi/usage_export_*.json | head -n 1)
|
||||||
|
ts=$(date +%Y-%m-%d_%H%M%S)
|
||||||
|
out=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.tar.gz
|
||||||
|
meta=/root/backups/cpa-runtime-daily/hwsg_usage_realtime_${ts}.meta.txt
|
||||||
|
mkdir -p /root/backups/cpa-runtime-daily
|
||||||
|
tar -czf ${out} ${latest}
|
||||||
|
sha=$(sha256sum ${out} | awk '{print $1}')
|
||||||
|
size=$(du -h ${out} | awk '{print $1}')
|
||||||
|
req=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_requests', data.get('total_requests','unknown')))" )
|
||||||
|
tok=$(python3 -c "import json; data=json.load(open('${latest}','r',encoding='utf-8')); u=data.get('usage',{}); print(u.get('total_tokens', data.get('total_tokens','unknown')))" )
|
||||||
|
{
|
||||||
|
echo "time=$(date '+%F %T %z')"
|
||||||
|
echo "source=${latest}"
|
||||||
|
echo "backup=${out}"
|
||||||
|
echo "sha256=${sha}"
|
||||||
|
echo "size=${size}"
|
||||||
|
echo "total_requests=${req}"
|
||||||
|
echo "total_tokens=${tok}"
|
||||||
|
} > ${meta}
|
||||||
|
cat ${meta}
|
||||||
|
|
||||||
|
- id: find_backup
|
||||||
|
action: shell.exec
|
||||||
|
on_fail: stop
|
||||||
|
with:
|
||||||
|
command: "ls -1 /root/backups/cpa-runtime-daily/${inputs.backup_id}.tar.gz"
|
||||||
|
|
||||||
|
- id: extract_backup
|
||||||
|
action: shell.exec
|
||||||
|
on_fail: stop
|
||||||
|
with:
|
||||||
|
command: "mkdir -p /tmp/cpa-restore && tar -xzf /root/backups/cpa-runtime-daily/${inputs.backup_id}.tar.gz -C /tmp/cpa-restore"
|
||||||
|
|
||||||
|
- id: import_usage
|
||||||
|
action: shell.exec
|
||||||
|
on_fail: stop
|
||||||
|
with:
|
||||||
|
command: |
|
||||||
|
CPA_TOKEN=${env.cpa_management_token}
|
||||||
|
CPA_BASE=https://cpa.pao.xx.kg/v0/management
|
||||||
|
latest=$(ls -1 /tmp/cpa-restore/root/cliproxyapi/usage_export_*.json 2>/dev/null | head -n 1)
|
||||||
|
if [ -z "$latest" ]; then
|
||||||
|
latest=$(ls -1 /tmp/cpa-restore/root/cliproxyapi/stats_persistence-*.json 2>/dev/null | head -n 1)
|
||||||
|
fi
|
||||||
|
if [ -z "$latest" ]; then
|
||||||
|
echo "no usage file found"; exit 1
|
||||||
|
fi
|
||||||
|
python3 -c "import json; json.load(open('${latest}','r',encoding='utf-8')); print('json_ok')"
|
||||||
|
resp=$(curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" -H "Content-Type: application/json" --data @${latest} ${CPA_BASE}/usage/import)
|
||||||
|
echo "$resp"
|
||||||
|
python3 -c "import json,sys; r=json.loads(sys.argv[1]); import sys as _s; _s.exit(r.get('error')) if isinstance(r,dict) and r.get('error') else print('import_ok')" "$resp"
|
||||||
|
|
||||||
|
- id: verify_now
|
||||||
|
action: shell.exec
|
||||||
|
on_fail: stop
|
||||||
|
with:
|
||||||
|
command: |
|
||||||
|
CPA_TOKEN=${env.cpa_management_token}
|
||||||
|
CPA_BASE=https://cpa.pao.xx.kg/v0/management
|
||||||
|
curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage
|
||||||
|
|
||||||
|
- id: verify_now_assert
|
||||||
|
action: assert.json
|
||||||
|
on_fail: stop
|
||||||
|
with:
|
||||||
|
source_step: verify_now
|
||||||
|
required_paths:
|
||||||
|
- "usage.total_requests"
|
||||||
|
- "usage.total_tokens"
|
||||||
|
|
||||||
|
- id: wait_10s
|
||||||
|
action: sleep
|
||||||
|
on_fail: continue
|
||||||
|
with:
|
||||||
|
ms: 10000
|
||||||
|
|
||||||
|
- id: verify_later
|
||||||
|
action: shell.exec
|
||||||
|
on_fail: stop
|
||||||
|
with:
|
||||||
|
command: |
|
||||||
|
CPA_TOKEN=${env.cpa_management_token}
|
||||||
|
CPA_BASE=https://cpa.pao.xx.kg/v0/management
|
||||||
|
curl -sS -H "Authorization: Bearer ${CPA_TOKEN}" ${CPA_BASE}/usage
|
||||||
|
|
||||||
|
- id: verify_later_assert
|
||||||
|
action: assert.json
|
||||||
|
on_fail: stop
|
||||||
|
with:
|
||||||
|
source_step: verify_later
|
||||||
|
required_paths:
|
||||||
|
- "usage.total_requests"
|
||||||
|
- "usage.total_tokens"
|
||||||
Reference in New Issue
Block a user