go20/devops/agent/script/runner.go
2026-03-08 18:05:17 +08:00

205 lines
6.6 KiB
Go

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
}