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 }