diff --git a/devops/README.md b/devops/README.md index 27c761a..b9d4973 100644 --- a/devops/README.md +++ b/devops/README.md @@ -1,3 +1,8 @@ # DevOps平台 - +```sh +# 初始化 devops平台工程 +➜ devops git:(main) ✗ go mod tidy +go: finding module for package github.com/infraboard/mcube/tools/pretty +go: found github.com/infraboard/mcube/tools/pretty in github.com/infraboard/mcube v1.9.29 +``` diff --git a/devops/agent/README.md b/devops/agent/README.md index 0c16958..f515b6a 100644 --- a/devops/agent/README.md +++ b/devops/agent/README.md @@ -1 +1,8 @@ -# DevOps Agent \ No newline at end of file +# DevOps Agent + +1. WebSocket Agent (Jenkins Node), 把自己注册到 Api Server 作为一个任务运行阶段 +2. 需要执行来自于 Api Server 下发的任务, 执行中需要把日志和执行结果返回给 Api Server +3. 怎么运行任务喃,我们是封装一个 script的模块, 然后调用这个模块来运行任务 +4. 任务需要有任务名称, 与 任务参数 这些基础信息 + + diff --git a/devops/agent/connect/README.md b/devops/agent/connect/README.md new file mode 100644 index 0000000..ac77c4c --- /dev/null +++ b/devops/agent/connect/README.md @@ -0,0 +1,4 @@ +# 连接管理 + +管理与Api的通信 + diff --git a/devops/agent/script/README.md b/devops/agent/script/README.md index 535f7bb..84489b2 100644 --- a/devops/agent/script/README.md +++ b/devops/agent/script/README.md @@ -3,10 +3,9 @@ 首先实现一个自己版本的 脚本执行器 核心功能: - + 支持workspace, 固定工作目录 -+ 制定脚本路径,可以直接执行 -+ 脚本输入是 环境变量 ++ 制定脚本路径,可以直接执行, 脚本存放的路径是可以配置, 默认是 shells 目录 ++ 脚本输入是 环境变量(环境变量有比较好的隔离性, 脚本的编写逻辑要清晰) + 输出到制定 文件(环境变量文件) + 执行过程中的日志 + debug.sh, 生产调试脚本, 基于改脚本,可以重复执行(记录执行中的参数,方便回放) diff --git a/devops/agent/script/debug_script.go b/devops/agent/script/debug_script.go new file mode 100644 index 0000000..9e1fd55 --- /dev/null +++ b/devops/agent/script/debug_script.go @@ -0,0 +1,78 @@ +package script + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// WriteDebugScript 生成debug.sh调试脚本到工作目录 +// 通过环境变量 DEBUG_SCRIPT 控制是否生成 (值为 true 或 1 时生成) +// 返回生成的文件路径,如果未生成则返回空字符串 +func (s *ExecuteScriptRequest) WriteDebugScript(scriptPath string, args []string) (string, error) { + if !s.isDebugScriptEnabled() { + return "", nil + } + + var sb strings.Builder + + sb.WriteString("#!/bin/bash\n") + sb.WriteString("# ========== 调试脚本 (自动生成) ==========\n") + sb.WriteString("# 警告: 此脚本可能包含敏感信息,请勿提交到版本控制系统\n") + fmt.Fprintf(&sb, "# 生成时间: %s\n", time.Now().Format("2006-01-02 15:04:05")) + fmt.Fprintf(&sb, "# 工作目录: %s\n", s.workDir) + fmt.Fprintf(&sb, "# 脚本路径: %s\n", scriptPath) + if len(args) > 0 { + fmt.Fprintf(&sb, "# 脚本参数: %v\n", args) + } + sb.WriteString("# ==========================================\n\n") + + sb.WriteString("set -e\n\n") + + // 设置环境变量 + if len(s.envVars) > 0 { + sb.WriteString("# 设置环境变量\n") + // 按key排序以保证输出一致性 + keys := make([]string, 0, len(s.envVars)) + for k := range s.envVars { + keys = append(keys, k) + } + // 简单排序 + for i := 0; i < len(keys); i++ { + for j := i + 1; j < len(keys); j++ { + if keys[i] > keys[j] { + keys[i], keys[j] = keys[j], keys[i] + } + } + } + for _, key := range keys { + value := s.envVars[key] + // 转义值中的特殊字符 + escapedValue := strings.ReplaceAll(value, "'", "'\"'\"'") + fmt.Fprintf(&sb, "export %s='%s'\n", key, escapedValue) + } + sb.WriteString("\n") + } + + // 执行脚本命令 + sb.WriteString("# 执行脚本\n") + sb.WriteString("exec ") + sb.WriteString(scriptPath) + for _, arg := range args { + sb.WriteString(" ") + // 转义参数中的特殊字符 + escapedArg := strings.ReplaceAll(arg, "'", "'\"'\"'") + fmt.Fprintf(&sb, " '%s'", escapedArg) + } + sb.WriteString("\n") + + // 写入文件 + debugScriptPath := filepath.Join(s.workDir, "debug.sh") + if err := os.WriteFile(debugScriptPath, []byte(sb.String()), 0755); err != nil { + return "", fmt.Errorf("failed to write debug script: %v", err) + } + + return debugScriptPath, nil +} diff --git a/devops/agent/script/hashes.json b/devops/agent/script/hashes.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/devops/agent/script/hashes.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/devops/agent/script/interface.go b/devops/agent/script/interface.go new file mode 100644 index 0000000..218f4ae --- /dev/null +++ b/devops/agent/script/interface.go @@ -0,0 +1,125 @@ +package script + +import ( + "fmt" + "os" + "os/exec" + "strings" + "time" +) + +// ExecuteScriptRequest 定义了执行脚本所需的参数和配置 +type ExecuteScriptRequest struct { + ScriptPath string + Args []string + Assets []string // 资产目录列表 + + // 脚本工作目录(默认当前目录) + workDir string + // 脚本执行环境变量(默认空) + envVars map[string]string + // 脚本执行日志文件路径(默认空,表示不记录日志) + logFile string + // 脚本执行超时时间(默认 0,表示不超时) + timeout time.Duration + // 脚本执行的命令元数据(可选),用于日志记录和监控 + metadata *CommandMetadata + // 脚本执行结果文件路径(默认空,表示不保存结果) + resultFile string + // 需要收集内容的文件列表 + collectFiles []string + + // 日志回调函数, 用于实时输出日志(默认 nil,表示不使用回调) + logCallback func(string) + + // 进程组管理控制参数, 用于避免脚本执行过程中,产生的子进程无法被正确杀死的问题 + // 是否创建新的进程组(默认 true,用于杀死子进程树) + // 设为 false 时,子进程不会被放入新的进程组,不能被组杀 + createProcessGroup bool + // 是否自定义 Cancel 函数以杀死进程组(默认 true) + // 设为 false 时,使用默认的进程杀死方式 + useProcessGroupKill bool + + // 命令执行信息 + cmd *exec.Cmd +} + +// SetEnv 设置环境变量, key会被强制转换为大写 +func (s *ExecuteScriptRequest) SetEnv(key, value string) { + key = strings.ToUpper(strings.TrimSpace(key)) + s.envVars[key] = value + if s.metadata != nil { + s.metadata.EnvVars[key] = value + } +} + +// buildEnv 构建环境变量, 将请求中的环境变量与系统环境变量合并,返回一个新的环境变量列表 +func (s *ExecuteScriptRequest) buildEnv() []string { + env := os.Environ() // 获取系统环境变量 + + // 补充自定义环境变量 + for key, value := range s.envVars { + env = append(env, fmt.Sprintf("%s=%s", key, value)) + } + + return env +} + +// isDebugScriptEnabled 检查是否启用调试脚本 +// 通过环境变量 DEBUG_SCRIPT 控制 (值为 true 或 1 时启用) +func (s *ExecuteScriptRequest) isDebugScriptEnabled() bool { + if val, ok := s.envVars["DEBUG_SCRIPT"]; ok { + return strings.EqualFold(val, "true") || val == "1" + } + return false +} + +func (r *ExecuteScriptRequest) WithWorkspacePrefix(workDirPrefix string) *ExecuteScriptRequest { + if strings.HasPrefix(r.workDir, workDirPrefix) { + return r + } + r.workDir = workDirPrefix + "/" + r.workDir + return r +} + +// CommandMetadata 命令元数据 +type CommandMetadata struct { + ID string `json:"id"` // 命令唯一ID + Name string `json:"name"` // 命令名称 + Description string `json:"description,omitempty"` // 命令描述 + Tags []string `json:"tags,omitempty"` // 标签 + CreatedBy string `json:"created_by"` // 创建者 + CreatedAt time.Time `json:"created_at"` // 创建时间 + Timeout time.Duration `json:"timeout"` // 超时时间 + EnvVars map[string]string `json:"env_vars"` // 环境变量 + WorkDir string `json:"work_dir"` // 工作目录 + RefTask string `json:"ref_task,omitempty"` // 关联的任务 +} + +// ExecutionResult 命令执行结果 +type ExecutionResult struct { + // 命令 + Command string `json:"command"` + // 错误原因 + Error string `json:"error,omitempty"` + // 命令退出码 + ExitCode int `json:"exit_code"` + // 命令开始执行时间 + StartTime time.Time `json:"start_time"` + // 命令结束执行时间 + EndTime *time.Time `json:"end_time"` + // 命令执行时长 + Duration time.Duration `json:"duration"` + // 命令执行是否成功 + Success bool `json:"success"` + // 是否跳过执行(跳过视为成功,但标记为 Skip 以便管道状态同步) + Skipped bool `json:"skipped,omitempty"` + // 非错误的说明信息(比如跳过原因等) + Message string `json:"message,omitempty"` + // 元数据 + Metadata *CommandMetadata `json:"metadata"` + // 文件内容集合 + FileContents map[string]string `json:"file_contents,omitempty"` + // 脚本输出参数(供下一个任务使用) + OutputParams map[string]string `json:"output_params,omitempty"` +} diff --git a/devops/agent/script/runner.go b/devops/agent/script/runner.go new file mode 100644 index 0000000..970d8fa --- /dev/null +++ b/devops/agent/script/runner.go @@ -0,0 +1,204 @@ +package script + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/rs/zerolog" +) + +// ScriptExcutor 脚本执行器,负责制定脚本的执行逻辑,比如准备环境变量、执行脚本、处理输出等 +type ScriptExcutor struct { + // 工作目录前缀,用于确保脚本执行在指定的目录下 + WorkDirPrefix string + // 脚本目录前缀,用于确保脚本路径在指定的目录下, 避免执行不安全的脚本 + ScriptDirPrefix string + // 是否启用脚本完整性校验, 通过编译时嵌入的脚本 hash 来校验脚本的完整性, 防止脚本被篡改 + IsVerifyScriptIntegrity bool + + // 日志记录器,用于记录脚本执行的日志信息 + log *zerolog.Logger + // 脚本完整性管理器,用于校验脚本的完整性 + integrityManager *ScriptIntegrityManager +} + +func (e *ScriptExcutor) Init() error { + // 初始化脚本完整性管理器 + e.integrityManager = NewScriptIntegrityManager(e.ScriptDirPrefix, e.IsVerifyScriptIntegrity) + return nil +} + +// ExecuteScript 执行脚本的核心方法,接受一个 ExecuteScriptRequest 请求,返回一个 ExecutionResult 结果 +func (e *ScriptExcutor) ExecuteScript(ctx context.Context, in *ExecuteScriptRequest) (*ExecutionResult, error) { + // 确保工作目录前缀, 避免脚本执行在不安全的目录下, 同时也方便清理执行结果 + in = in.WithWorkspacePrefix(e.WorkDirPrefix) + + // 确保工作目录存在 + if err := e.ensureWorkDir(in.workDir); err != nil { + e.log.Error().Str("task_id", in.metadata.ID).Str("work_dir", in.workDir).Err(err).Msg("创建工作目录失败") + return nil, err + } + + // 确保元数据存在 + if in.metadata == nil { + in.metadata = &CommandMetadata{ + ID: generateID(), + CreatedBy: getCurrentUser(), + CreatedAt: time.Now(), + WorkDir: in.workDir, + EnvVars: in.envVars, + Timeout: in.timeout, + } + } + + // zerolog 的用法 + e.log.Info().Str("task_id", in.metadata.ID).Str("script_path", in.ScriptPath).Msg("准备执行脚本") + + // 脚本路径消毒 + scriptPath := e.WithPrefixScripPath(in.ScriptPath) + fullScriptPath, err := e.sanitizeScriptPath(scriptPath) + if err != nil { + e.log.Error().Str("task_id", in.metadata.ID).Str("script_path", scriptPath).Err(err).Msg("脚本路径验证失败") + return nil, err + } + e.log.Info().Str("task_id", in.metadata.ID).Str("full_script_path", fullScriptPath).Msg("脚本路径验证成功") + + // 校验脚本完整性 + if err := e.integrityManager.VerifyScript(fullScriptPath); err != nil { + e.log.Error().Str("task_id", in.metadata.ID).Str("script_path", fullScriptPath).Err(err).Msg("脚本完整性校验失败") + return nil, fmt.Errorf("脚本完整性校验失败: %v", err) + } + + // 构建完整的shell命令 + shellArgs := []string{fullScriptPath} + shellArgs = append(shellArgs, in.Args...) + shellCommand := strings.Join(shellArgs, " ") + + e.log.Info().Str("task_id", in.metadata.ID).Str("command", shellCommand).Msg("脚本执行命令") + + // 注入标准变量 + e.InjectEnv(in) + + // 根据超时设置创建 context + execCtx := ctx + var cancel context.CancelFunc + if in.timeout > 0 { + execCtx, cancel = context.WithTimeout(ctx, in.timeout) + defer cancel() + e.log.Info().Str("task_id", in.metadata.ID).Dur("timeout", in.timeout).Msg("设置脚本执行超时时间") + } + + // 创建命令 + cmd := exec.CommandContext(execCtx, "/bin/sh", "-c", shellCommand) + cmd.Dir = in.workDir + cmd.Env = in.buildEnv() + // 根据参数设置进程组属性 + if in.createProcessGroup { + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setpgid: true, // 创建新的进程组 + } + } + + // 根据参数设置自定义 Cancel 函数 + if in.useProcessGroupKill { + // 自定义 Cancel 函数,确保杀死进程组 + cmd.Cancel = func() error { + if cmd.Process == nil { + return nil + } + // 使用负 PID 杀死整个进程组 + return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + } + + in.cmd = cmd + + // 脚本执行生成调试脚本(避免被脚本内部的 git clone 等操作清空) + if in.isDebugScriptEnabled() { + if debugScriptPath, writeErr := in.WriteDebugScript(fullScriptPath, in.Args); writeErr != nil { + e.log.Warn().Str("task_id", in.metadata.ID).Err(writeErr).Msg("生成调试脚本失败") + } else if debugScriptPath != "" { + e.log.Info().Str("task_id", in.metadata.ID).Str("debug_script", debugScriptPath).Msg("已生成调试脚本,可进入工作目录执行 ./debug.sh 进行调试") + } + } + + return nil, nil +} + +func (e *ScriptExcutor) InjectEnv(in *ExecuteScriptRequest) { + // 注入标准变量 + in.SetEnv("SCRIPT_DIR", e.ScriptDirPrefix) + in.SetEnv("WORKSPACE", in.workDir) + + // 创建 output.env 文件(用于脚本输出参数) + outputEnvFile := filepath.Join(in.workDir, "output.env") + // 创建空文件(如果已存在则清空) + if f, err := os.Create(outputEnvFile); err == nil { + f.Close() + // 注入环境变量,让脚本可以直接使用 + in.SetEnv("OUTPUT_ENV_FILE", outputEnvFile) + } +} + +// ensureWorkDir 确保工作目录存在 +func (e *ScriptExcutor) ensureWorkDir(workspace string) error { + if workspace == "" { + return fmt.Errorf("work directory cannot be empty") + } + + if _, err := os.Stat(workspace); os.IsNotExist(err) { + if err := os.MkdirAll(workspace, 0755); err != nil { + return err + } + } + return nil +} + +func (r *ScriptExcutor) WithPrefixScripPath(scriptName string) string { + return r.ScriptDirPrefix + "/" + scriptName +} + +// sanitizeScriptPath 脚本路径消毒, 确保脚本路径在指定的目录下,并且文件存在且可执行 +func (s *ScriptExcutor) sanitizeScriptPath(scriptPath string) (string, error) { + if strings.TrimSpace(scriptPath) == "" { + return "", fmt.Errorf("script path cannot be empty") + } + + // 确保脚本路径在工作目录内 + scriptFullPath := scriptPath + if !filepath.IsAbs(scriptPath) { + // 直接获取绝对路径 + fullPath, err := filepath.Abs(scriptPath) + if err != nil { + return "", fmt.Errorf("failed to get absolute path for script path %s: %v", scriptFullPath, err) + } + scriptFullPath = fullPath + } + + // 检查文件是否存在 + if _, err := os.Stat(scriptFullPath); os.IsNotExist(err) { + return "", fmt.Errorf("script file does not exist: %s", scriptFullPath) + } + + // 检查文件是否可执行 + info, err := os.Stat(scriptFullPath) + if err != nil { + return "", fmt.Errorf("failed to get script file info: %v", err) + } + + // 如果是脚本文件,确保它有执行权限 + if info.Mode().Perm()&0111 == 0 { + // 尝试添加执行权限 + if err := os.Chmod(scriptFullPath, info.Mode()|0111); err != nil { + return "", fmt.Errorf("script file is not executable and cannot be made executable: %v", err) + } + } + + return scriptFullPath, nil +} diff --git a/devops/agent/script/script_integrity.go b/devops/agent/script/script_integrity.go new file mode 100644 index 0000000..24ea217 --- /dev/null +++ b/devops/agent/script/script_integrity.go @@ -0,0 +1,169 @@ +package script + +import ( + "crypto/sha256" + "embed" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "sync" + + "github.com/infraboard/mcube/v2/ioc/config/log" + "github.com/rs/zerolog" +) + +// 编译时嵌入的脚本 hash 文件 +// +//go:embed hashes.json +var scriptHashesFS embed.FS + +// ScriptIntegrityManager 脚本完整性管理器 +// 脚本 hash 在编译时计算并硬编码到二进制中 +// 在执行脚本前校验 hash,防止脚本被篡改 +// 这确保了生产环境的脚本不会被篡改,即使脚本目录变为可写 +type ScriptIntegrityManager struct { + log *zerolog.Logger + mu sync.RWMutex + + // 注册的脚本 hash (相对路径 -> hash),从编译时常量加载 + registeredHashes map[string]string + + // 脚本根目录 + scriptDir string + + // 是否启用校验(编译时参数) + enabled bool +} + +// NewScriptIntegrityManager 创建脚本完整性管理器 +// 从编译时嵌入的 hash 文件加载脚本 hash +func NewScriptIntegrityManager(scriptDir string, enabled bool) *ScriptIntegrityManager { + m := &ScriptIntegrityManager{ + log: log.Sub("script_integrity"), + registeredHashes: make(map[string]string), + scriptDir: scriptDir, + enabled: enabled, + } + + // 如果启用了校验,加载编译时的 hash + if enabled { + if err := m.loadCompiledHashes(); err != nil { + m.log.Error().Err(err).Msg("加载编译时的脚本 hash 失败") + } + } + + return m +} + +// loadCompiledHashes 从编译时嵌入的 hash 文件加载脚本 hash +func (m *ScriptIntegrityManager) loadCompiledHashes() error { + data, err := scriptHashesFS.ReadFile("hashes.json") + if err != nil { + return fmt.Errorf("读取嵌入的 hash 文件失败: %v", err) + } + + var hashes map[string]string + if err := json.Unmarshal(data, &hashes); err != nil { + return fmt.Errorf("解析 hash 文件失败: %v", err) + } + + m.mu.Lock() + m.registeredHashes = hashes + m.mu.Unlock() + + m.log.Info().Int("count", len(hashes)).Msg("加载编译时的脚本 hash 完成") + return nil +} + +// Enable 启用校验 +func (m *ScriptIntegrityManager) Enable() { + m.mu.Lock() + defer m.mu.Unlock() + m.enabled = true +} + +// Disable 禁用校验 +func (m *ScriptIntegrityManager) Disable() { + m.mu.Lock() + defer m.mu.Unlock() + m.enabled = false +} + +// IsEnabled 是否启用校验 +func (m *ScriptIntegrityManager) IsEnabled() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.enabled +} + +// VerifyScript 校验脚本完整性 +// 在执行脚本前调用,验证脚本是否被篡改 +func (m *ScriptIntegrityManager) VerifyScript(scriptPath string) error { + if !m.enabled { + return nil + } + + // 计算相对路径 + relPath, err := filepath.Rel(m.scriptDir, scriptPath) + if err != nil { + return fmt.Errorf("计算相对路径失败: %v", err) + } + + m.mu.RLock() + expectedHash, exists := m.registeredHashes[relPath] + m.mu.RUnlock() + + if !exists { + return fmt.Errorf("脚本未注册: %s (可能是新增的脚本,请重启 Agent)", relPath) + } + + // 计算当前 hash + currentHash, err := m.calculateFileHash(scriptPath) + if err != nil { + return fmt.Errorf("计算脚本 hash 失败: %v", err) + } + + // 对比 hash + if currentHash != expectedHash { + m.log.Error(). + Str("script", relPath). + Str("expected_hash", expectedHash). + Str("current_hash", currentHash). + Msg("脚本完整性校验失败:脚本可能被篡改") + return fmt.Errorf("脚本完整性校验失败: %s (hash 不匹配)", relPath) + } + + m.log.Debug().Str("script", relPath).Str("hash", currentHash).Msg("脚本完整性校验通过") + return nil +} + +// calculateFileHash 计算文件的 SHA256 hash +func (m *ScriptIntegrityManager) calculateFileHash(filePath string) (string, error) { + file, err := os.Open(filePath) + if err != nil { + return "", err + } + defer file.Close() + + hash := sha256.New() + if _, err := io.Copy(hash, file); err != nil { + return "", err + } + + return hex.EncodeToString(hash.Sum(nil)), nil +} + +// GetRegisteredScripts 获取已注册的脚本列表 +func (m *ScriptIntegrityManager) GetRegisteredScripts() map[string]string { + m.mu.RLock() + defer m.mu.RUnlock() + + // 返回副本 + result := make(map[string]string, len(m.registeredHashes)) + maps.Copy(result, m.registeredHashes) + return result +} diff --git a/devops/agent/script/tools.go b/devops/agent/script/tools.go new file mode 100644 index 0000000..31dff53 --- /dev/null +++ b/devops/agent/script/tools.go @@ -0,0 +1,22 @@ +package script + +import ( + "fmt" + "os" + "time" +) + +// 工具函数 +func generateID() string { + return fmt.Sprintf("cmd_%d", time.Now().UnixNano()) +} + +func getCurrentUser() string { + if user := os.Getenv("USER"); user != "" { + return user + } + if user := os.Getenv("USERNAME"); user != "" { + return user + } + return "unknown" +} diff --git a/devops/agent/shells/README.md b/devops/agent/shells/README.md new file mode 100644 index 0000000..d3115a6 --- /dev/null +++ b/devops/agent/shells/README.md @@ -0,0 +1,10 @@ +# 脚本存放目录 + + + +## 系统变量 + +脚本执行过程中,会自动设置一些系统变量 + ++ WORKSPACE:工作目录 ++ SCRIPT_DIR:脚本存放目录 \ No newline at end of file diff --git a/devops/agent/shells/lib.sh b/devops/agent/shells/lib.sh new file mode 100644 index 0000000..5d85e02 --- /dev/null +++ b/devops/agent/shells/lib.sh @@ -0,0 +1,33 @@ +#!/bin/bash + +# 公共库脚本 +# 包含一些常用的函数和变量定义,供其他脚本调用 + +# 颜色定义 +COLOR_RED='\033[0;31m' +COLOR_GREEN='\033[0;32m' +COLOR_YELLOW='\033[0;33m' +COLOR_CYAN_BOLD='\033[1;36m' +COLOR_RESET='\033[0m' + +# 日志函数 +log_info() { + echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') - $1" +} + +log_success() { + echo -e "${COLOR_GREEN}[SUCCESS] $(date '+%Y-%m-%d %H:%M:%S') - $1${COLOR_RESET}" +} + +log_warning() { + echo -e "${COLOR_YELLOW}[WARNING] $(date '+%Y-%m-%d %H:%M:%S') - $1${COLOR_RESET}" +} + +log_error() { + echo -e "${COLOR_RED}[ERROR] $(date '+%Y-%m-%d %H:%M:%S') - $1${COLOR_RESET}" +} + +# 强调/高亮日志(用于强调性提示) +log_highlight() { + echo -e "${COLOR_CYAN_BOLD}[HINT] $(date '+%Y-%m-%d %H:%M:%S') - $1${COLOR_RESET}" +} diff --git a/devops/agent/shells/task_debug.sh b/devops/agent/shells/task_debug.sh new file mode 100644 index 0000000..67729e0 --- /dev/null +++ b/devops/agent/shells/task_debug.sh @@ -0,0 +1,208 @@ +#!/bin/bash + +# Task Debug Script +# 用于打印任务的详细信息,包括所有环境变量 +# 主要用于调试和排查问题 + +set -e + +# 引入公共库 +source "${SCRIPT_DIR:-.}/lib.sh" + +# 打印分隔线 +print_separator() { + log_info "========================================" +} + +# 打印标题 +print_title() { + print_separator + echo " $1" + print_separator +} + +# 打印键值对 +print_kv() { + printf "%-30s : %s\n" "$1" "$2" +} + +# 主函数 +main() { + log_highlight "开始任务调试信息输出" + + # 1. 打印任务基本信息 + print_title "任务基本信息" + print_kv "任务ID" "${TASK_ID:-未设置}" + print_kv "任务名称" "${TASK_NAME:-未设置}" + print_kv "任务类型" "${TASK_TYPE:-未设置}" + print_kv "任务状态" "${TASK_STATUS:-未设置}" + print_kv "任务描述" "${TASK_DESCRIPTION:-未设置}" + print_kv "执行者" "${TASK_RUN_BY:-未设置}" + print_kv "Agent 环境" "${TASK_AGENT_ENV:-未设置}" + print_kv "调度的 Agent" "${TASK_SCHEDULED_AGENT:-未设置}" + echo "" + + # 2. 打印标准环境变量 + print_title "标准环境变量" + print_kv "工作目录" "${WORKSPACE:-未设置}" + print_kv "脚本目录" "${SCRIPT_DIR:-未设置}" + print_kv "用户" "${USER:-未设置}" + print_kv "主机名" "${HOSTNAME:-未设置}" + print_kv "PWD" "${PWD:-未设置}" + print_kv "HOME" "${HOME:-未设置}" + print_kv "SHELL" "${SHELL:-未设置}" + echo "" + + # 3. 打印任务参数(PARAM_ 开头) + print_title "任务参数 (PARAM_*)" + local param_found=false + while IFS='=' read -r name value; do + if [[ $name == PARAM_* ]]; then + param_found=true + # 移除 PARAM_ 前缀显示 + local param_name="${name#PARAM_}" + print_kv "$param_name" "$value" + fi + done < <(env | sort) + + if [ "$param_found" = false ]; then + echo " (无任务参数)" + fi + echo "" + + # 4. 打印任务定义(DEFINE_ 开头) + print_title "任务定义 (DEFINE_*)" + local define_found=false + while IFS='=' read -r name value; do + if [[ $name == DEFINE_* ]]; then + define_found=true + # 移除 DEFINE_ 前缀显示 + local define_name="${name#DEFINE_}" + print_kv "$define_name" "$value" + fi + done < <(env | sort) + + if [ "$define_found" = false ]; then + echo " (无任务定义)" + fi + echo "" + + # 5. 打印所有环境变量(按字母排序) + print_title "所有环境变量" + env | sort | while IFS='=' read -r name value; do + # 截断过长的值 + if [ ${#value} -gt 100 ]; then + value="${value:0:100}... (truncated)" + fi + print_kv "$name" "$value" + done + echo "" + + # 6. 打印系统信息 + print_title "系统信息" + print_kv "操作系统" "$(uname -s)" + print_kv "内核版本" "$(uname -r)" + print_kv "架构" "$(uname -m)" + + if command -v lsb_release &> /dev/null; then + print_kv "发行版" "$(lsb_release -d | cut -f2-)" + elif [ -f /etc/os-release ]; then + print_kv "发行版" "$(grep PRETTY_NAME /etc/os-release | cut -d'"' -f2)" + fi + + # 打印资源使用情况 + if command -v free &> /dev/null; then + local mem_info=$(free -h | grep Mem | awk '{print $3 "/" $2 " (used/total)"}') + print_kv "内存使用" "$mem_info" + fi + + if command -v df &> /dev/null; then + local disk_info=$(df -h . | tail -1 | awk '{print $3 "/" $2 " (" $5 " used)"}') + print_kv "磁盘使用" "$disk_info" + fi + + print_kv "CPU 核心数" "$(nproc 2>/dev/null || echo 'unknown')" + print_kv "当前时间" "$(date '+%Y-%m-%d %H:%M:%S %Z')" + echo "" + + # 7. 打印工作目录内容 + print_title "工作目录内容" + if [ -d "${WORKSPACE}" ]; then + print_kv "工作目录路径" "${WORKSPACE}" + echo "" + echo "文件列表:" + ls -lh "${WORKSPACE}" 2>/dev/null | tail -n +2 | while read -r line; do + echo " $line" + done + else + echo " 工作目录不存在: ${WORKSPACE:-未设置}" + fi + echo "" + + # 8. 打印网络信息 + print_title "网络信息" + if command -v ip &> /dev/null; then + echo "IP 地址:" + ip addr show | grep -E "inet |inet6 " | awk '{print " " $0}' + elif command -v ifconfig &> /dev/null; then + echo "IP 地址:" + ifconfig | grep -E "inet |inet6 " | awk '{print " " $0}' + fi + echo "" + + # 9. 打印 Docker 信息(如果可用) + if command -v docker &> /dev/null; then + print_title "Docker 信息" + print_kv "Docker 版本" "$(docker --version 2>/dev/null | cut -d' ' -f3 | tr -d ',')" + + # 检查 Docker 是否运行 + if docker info &> /dev/null; then + local running_containers=$(docker ps -q | wc -l) + local all_containers=$(docker ps -aq | wc -l) + print_kv "运行中的容器" "$running_containers" + print_kv "总容器数" "$all_containers" + + local images_count=$(docker images -q | wc -l) + print_kv "镜像数量" "$images_count" + else + echo " Docker daemon 未运行" + fi + echo "" + fi + + # 10. 打印进程信息 + print_title "进程信息" + print_kv "当前进程 PID" "$$" + print_kv "父进程 PID" "$PPID" + + if command -v ps &> /dev/null; then + echo "" + echo "当前进程树:" + ps auxf 2>/dev/null | grep -E "(PID|$$)" | head -5 | while read -r line; do + echo " $line" + done + fi + echo "" + + # 11. 环境变量统计 + print_title "环境变量统计" + local total_env=$(env | wc -l) + local param_count=$(env | grep -c "^PARAM_" || echo 0) + local define_count=$(env | grep -c "^DEFINE_" || echo 0) + local task_count=$(env | grep -c "^TASK_" || echo 0) + + print_kv "总环境变量数" "$total_env" + print_kv "任务参数数 (PARAM_*)" "$param_count" + print_kv "任务定义数 (DEFINE_*)" "$define_count" + print_kv "任务信息数 (TASK_*)" "$task_count" + echo "" + + print_separator + log_success "任务调试信息输出完成" + + return 0 +} + +# 执行主函数 +main "$@" +exit $? \ No newline at end of file diff --git a/devops/agent/tasks/README.md b/devops/agent/tasks/README.md new file mode 100644 index 0000000..f78bc34 --- /dev/null +++ b/devops/agent/tasks/README.md @@ -0,0 +1,4 @@ +# 任务执行模块 + +负责任务的具体执行 + diff --git a/devops/agent/tasks/interface.go b/devops/agent/tasks/interface.go new file mode 100644 index 0000000..8e60848 --- /dev/null +++ b/devops/agent/tasks/interface.go @@ -0,0 +1,13 @@ +package tasks + +import ( + "context" + "devops/server/apps/task" +) + +// Task 是一个接口,定义了任务的基本行为 +// 任务名称: task_debug, 任务描述: 调试任务, 任务类型: debug, 任务参数: {} +type TaskRunner interface { + // 任务需要的运行能力 + Run(context.Context, *task.TaskSpec) (*task.Task, error) +} diff --git a/devops/agent/tasks/task_debug/README.md b/devops/agent/tasks/task_debug/README.md new file mode 100644 index 0000000..b62881e --- /dev/null +++ b/devops/agent/tasks/task_debug/README.md @@ -0,0 +1,5 @@ +# Task Debug 任务 + + + + diff --git a/devops/agent/tasks/task_debug/impl.go b/devops/agent/tasks/task_debug/impl.go new file mode 100644 index 0000000..eae2419 --- /dev/null +++ b/devops/agent/tasks/task_debug/impl.go @@ -0,0 +1,15 @@ +package taskdebug + +import ( + "context" + "devops/server/apps/task" +) + +// 实现一个 task_debug 任务 + +type TaskDebugRunner struct{} + +func (t *TaskDebugRunner) Run(ctx context.Context, spec *task.TaskSpec) (*task.Task, error) { + // 使用脚本执行 + return nil, nil +} diff --git a/devops/docs/agent.drawio b/devops/docs/agent.drawio new file mode 100644 index 0000000..677284a --- /dev/null +++ b/devops/docs/agent.drawio @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/devops/docs/agent.png b/devops/docs/agent.png new file mode 100644 index 0000000..90a6443 Binary files /dev/null and b/devops/docs/agent.png differ diff --git a/devops/go.mod b/devops/go.mod new file mode 100644 index 0000000..b80ccd7 --- /dev/null +++ b/devops/go.mod @@ -0,0 +1,25 @@ +module devops + +go 1.25.6 + +require ( + github.com/infraboard/mcube v1.9.29 + github.com/infraboard/mcube/v2 v2.1.3 + github.com/rs/zerolog v1.34.0 +) + +require ( + github.com/BurntSushi/toml v1.5.0 // indirect + github.com/caarlos0/env/v6 v6.10.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sys v0.35.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/devops/go.sum b/devops/go.sum new file mode 100644 index 0000000..2fd6380 --- /dev/null +++ b/devops/go.sum @@ -0,0 +1,52 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/caarlos0/env/v6 v6.10.1 h1:t1mPSxNpei6M5yAeu1qtRdPAK29Nbcf/n3G7x+b3/II= +github.com/caarlos0/env/v6 v6.10.1/go.mod h1:hvp/ryKXKipEkcuYjs9mI4bBCg+UI0Yhgm5Zu0ddvwc= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/infraboard/mcube v1.9.29 h1:sta2Ca+H83sXaQFaTKAX2uVwsXKhAbFbbUr5m1El3UE= +github.com/infraboard/mcube v1.9.29/go.mod h1:5VqpDng1zHVoLF9WXYelO/jV0WkxSURooVSHzMznx0U= +github.com/infraboard/mcube/v2 v2.1.3 h1:2UCceLoMkcjxp7btEZQgajyBW/Tzf7meB4OwEA8Hzs4= +github.com/infraboard/mcube/v2 v2.1.3/go.mod h1:M/UxG9LsdiBVdMKnoCnDOzr3CR7PNBXsygTbB5U6Ibg= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= +go.yaml.in/yaml/v3 v3.0.3/go.mod h1:tBHosrYAkRZjRAOREWbDnBXUf08JOwYq++0QNwQiWzI= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/devops/server/apps/README.md b/devops/server/apps/README.md new file mode 100644 index 0000000..2605aa1 --- /dev/null +++ b/devops/server/apps/README.md @@ -0,0 +1,2 @@ +# 业务模块存放模块 + diff --git a/devops/server/apps/task/enum.go b/devops/server/apps/task/enum.go new file mode 100644 index 0000000..3461fd2 --- /dev/null +++ b/devops/server/apps/task/enum.go @@ -0,0 +1,27 @@ +package task + +type STATUS string + +func (s STATUS) IsComplete() bool { + return s == STATUS_SUCCESS || s == STATUS_FAILED || s == STATUS_SKIP || s == STATUS_CANCELED +} + +func (s STATUS) String() string { + return string(s) +} + +const ( + STATUS_PENDDING STATUS = "等待处理" + STATUS_RUNNING STATUS = "运行中" + // 忽略执行, 等同为成功 + STATUS_SKIP STATUS = "忽略执行" + STATUS_SUCCESS STATUS = "成功" + STATUS_CANCELED STATUS = "取消" + STATUS_FAILED STATUS = "失败" +) + +const ( + CONDITION_OPERATOR_IN CONDITION_OPERATOR = "in" +) + +type CONDITION_OPERATOR string diff --git a/devops/server/apps/task/model.go b/devops/server/apps/task/model.go new file mode 100644 index 0000000..b31e241 --- /dev/null +++ b/devops/server/apps/task/model.go @@ -0,0 +1,240 @@ +package task + +import ( + "fmt" + "time" + + "github.com/infraboard/mcube/tools/pretty" +) + +type Task struct { + // 任务定义 + *TaskSpec + // 任务状态 + *TaskStatus +} + +func (e *Task) TableName() string { + return "devops_tasks" +} + +type TaskSpec struct { + // 任务Id(唯一标识,由调用方生成, 比如 uuid, 如果没有自动生成唯一Id) + Id string `json:"id" gorm:"column:id;type:string;primary_key"` + // 描述, 比如 "构建任务", "部署任务" + Description string `json:"description" gorm:"column:description;type:text"` + // 任务名称, 比如 "build", "deploy" 每一个名称 在Agent测有一个唯一的Task与之对应 + Name string `json:"name" gorm:"column:name;type:varchar(255)"` + // 创建时间 + CreateAt time.Time `json:"create_at" gorm:"column:create_at;type:datetime"` + // 任务定义, 比如名称, job定义 + Define string `json:"define" gorm:"column:define;type:text"` + // 运行参数, 比如构建任务的代码分支、部署任务的目标环境等 作为环境变量传递给任务脚本执行 + InputParams map[string]string `json:"input_params" gorm:"column:input_params;type:json;serializer:json;not null;default:'{}'"` + // 流水线任务Id(忽略) + PipelineTaskId string `json:"pipeline_task_id" gorm:"column:pipeline_task_id;type:varchar(100);index"` + + // 任务超时时间, 0表示不超时 + TimeoutSecond int64 `json:"timeout_second" gorm:"column:timeout_second;type:bigint"` + // 是否忽略错误 + IgnoreError *bool `json:"ignore_error" gorm:"column:ignore_error;type:bool;default:false"` + // 需要调度那个Agent执行, 为空表示不指定, 由调度系统根据任务类型和Agent能力自动选择一个Agent执行 + ScheduleAgent string `json:"schedule_agent" gorm:"column:schedule_agent;type:varchar(255);index"` + // 依赖的任务节点列表 + DependsTasks []string `json:"depends_tasks" gorm:"column:depends_tasks;type:json;serializer:json;not null;default:'[]'"` + // 执行条件(旧版,保留向后兼容) + When []Contiditon `json:"when" gorm:"column:when;type:json;serializer:json;"` + // when 条件表达式(新版 DAG 条件系统) + // 支持表达式如: "always", "never", "params.env == 'prod'", "deps.build.status == 'success'" + WhenCondition string `json:"when_condition" gorm:"column:when_condition;type:varchar(1000)"` + // 额外的其他属性 + Extras map[string]string `json:"extras" form:"extras" gorm:"column:extras;type:json;serializer:json;"` + // 标签 + Label map[string]string `json:"label" gorm:"column:label;type:json;serializer:json;"` +} + +// inputa == "a" +type Contiditon struct { + // 输入参数 + InputParam string `json:"input_param"` + // 操作符 + Operator CONDITION_OPERATOR `json:"operator"` + // In的值列吧 + Values []string `json:"values"` +} + +type TaskStatus struct { + // 状态 + Status STATUS `json:"status" gorm:"column:status;type:varchar(100);index"` + // 关联URL (比如日志URL, 结果URL等) + RefURL string `json:"ref_url" gorm:"column:ref_url;type:varchar(255)"` + // 失败原因 + Message string `json:"message" gorm:"column:message;type:text"` + // 异步任务调用时的返回详情 + Detail string `json:"detail,omitempty" gorm:"column:detail;type:text"` + // 启动人 + RunBy string `json:"run_by" gorm:"column:run_by;type:varchar(100)"` + // 开始时间 + StartAt *time.Time `json:"start_at" gorm:"column:start_at;type:datetime"` + // 更新时间 + UpdateAt time.Time `json:"update_at" gorm:"column:update_at;type:datetime"` + // 结束时间 + EndAt time.Time `json:"end_at" gorm:"column:end_at;type:datetime"` + // 其他信息 + Extras map[string]string `json:"extras" gorm:"column:extras;type:json;serializer:json;not null;default:'{}'"` + + // 调度时间 + ScheduledAt *time.Time `json:"scheduled_at" gorm:"column:scheduled_at;type:datetime"` + // 具体执行任务的AgentId + ScheduledAgentId *string `json:"scheduled_agent" gorm:"column:scheduled_agent;type:varchar(255);index"` + // 确认调度超时, 默认15秒 + ScheduledConfirmTTL int64 `json:"scheduled_confirm_ttl" gorm:"column:scheduled_confirm_ttl;type:bigint;default:15"` + // 调度确认, Agent确认接收任务, 下发成功后设置为true + ScheduledConfirmed *bool `json:"scheduled_confirmed" gorm:"column:scheduled_confirmed;type:boolean;default:false"` + + // 任务输出参数(供下一个任务使用) + Output map[string]string `json:"output,omitempty" gorm:"column:output;type:json;serializer:json;not null;default:'{}'"` +} + +// // 命令 +// Command string `json:"command"` +// // 错误原因 +// Error string `json:"error,omitempty"` +// // 命令退出码 +// ExitCode int `json:"exit_code"` +// // 命令开始执行时间 +// StartTime time.Time `json:"start_time"` +// // 命令结束执行时间 +// EndTime *time.Time `json:"end_time"` +// // 命令执行时长 +// Duration time.Duration `json:"duration"` +// // 命令执行是否成功 +// Success bool `json:"success"` +// // 是否跳过执行(跳过视为成功,但标记为 Skip 以便管道状态同步) +// Skipped bool `json:"skipped,omitempty"` +// // 非错误的说明信息(比如跳过原因等) +// Message string `json:"message,omitempty"` +// // 元数据 +// Metadata *CommandMetadata `json:"metadata"` +// // 文件内容集合 +// FileContents map[string]string `json:"file_contents,omitempty"` +// // 脚本输出参数(供下一个任务使用) +// OutputParams map[string]string `json:"output_params,omitempty"` + +func (r *TaskStatus) String() string { + return pretty.ToJSON(r) +} + +func (r *TaskStatus) SetScheduledConfirmed(confirmed bool) *TaskStatus { + r.ScheduledConfirmed = &confirmed + return r +} + +func (r *TaskStatus) SetScheduledAgentId(agentId string) *TaskStatus { + r.ScheduledAgentId = &agentId + return r +} + +func (r *TaskStatus) GetScheduledAgentId() string { + if r.ScheduledAgentId == nil { + return "" + } + return *r.ScheduledAgentId +} + +func (r *TaskStatus) TableName() string { + return "devops_tasks" +} + +func (r *TaskStatus) MarkedRunning() { + r.setStartAt(time.Now()) + r.Status = STATUS_RUNNING +} + +func (r *TaskStatus) IsRunning() bool { + return r.Status == STATUS_RUNNING +} + +// MarkScheduled 标记任务已被分配给指定的Agent +// 用于Agent模式下的任务分发 +// 同时设置确认超时时间窗口(默认15秒) +func (r *TaskStatus) MarkScheduled(agentId string) *TaskStatus { + now := time.Now() + r.ScheduledAt = &now + r.SetScheduledAgentId(agentId) + r.SetScheduledConfirmed(false) + // 确保设置确认超时时间,如果未设置则默认15秒 + if r.ScheduledConfirmTTL <= 0 { + r.ScheduledConfirmTTL = 15 + } + return r +} + +// IsScheduled 检查任务是否已被分配给Agent +func (r *TaskStatus) IsScheduled() bool { + return r.ScheduledAgentId != nil && *r.ScheduledAgentId != "" +} + +// IsScheduleConfirm 检查任务是否具有完整的调度信息(调度时间和超时时间都已设置) +// 用于判断是否需要进行调度超时检查 +func (r *TaskStatus) IsScheduleConfirm() bool { + return r.ScheduledAt != nil && r.ScheduledConfirmTTL > 0 +} + +// ConfirmScheduled 确认Agent已接收任务分配 +func (r *TaskStatus) ConfirmScheduled() *TaskStatus { + r.SetScheduledConfirmed(true) + return r +} + +// IsScheduleConfirmed 检查任务分配是否已被Agent确认 +func (r *TaskStatus) IsScheduleConfirmed() bool { + return r.ScheduledConfirmed != nil && *r.ScheduledConfirmed +} + +func (r *TaskStatus) setStartAt(t time.Time) { + r.StartAt = &t +} + +func (t *TaskStatus) WithExtra(key, value string) *TaskStatus { + t.Extras[key] = value + return t +} + +func (t *TaskStatus) WithRefURL(refURL string) *TaskStatus { + t.RefURL = refURL + return t +} + +func (t *TaskStatus) Failedf(format string, a ...any) *TaskStatus { + t.EndAt = time.Now() + t.Status = STATUS_FAILED + t.Message = fmt.Sprintf(format, a...) + return t +} + +func (t *TaskStatus) Canceledf(format string, a ...any) *TaskStatus { + t.EndAt = time.Now() + t.Status = STATUS_CANCELED + t.Message = fmt.Sprintf(format, a...) + return t +} + +func (t *TaskStatus) Success(format string, a ...any) *TaskStatus { + t.EndAt = time.Now() + t.Status = STATUS_SUCCESS + t.Message = fmt.Sprintf(format, a...) + return t +} + +func (t *TaskStatus) Skipf(format string, a ...any) *TaskStatus { + t.EndAt = time.Now() + t.Status = STATUS_SKIP + t.Message = fmt.Sprintf(format, a...) + return t +} + +func (t *TaskStatus) WithDetail(format string, a ...any) *TaskStatus { + t.Detail = fmt.Sprintf(format, a...) + return t +}