package script import ( "context" "encoding/json" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "syscall" "time" "github.com/infraboard/mcube/v2/ioc" "github.com/infraboard/mcube/v2/ioc/config/log" "github.com/rs/zerolog" ) // 把ScriptExcutor 托管给IOC管理 func init() { ioc.Controller().Registry(&ScriptExcutor{ WorkDirPrefix: "workspace", ScriptDirPrefix: "shells", IsVerifyScriptIntegrity: false, runningCommands: make(map[string]*ExecuteScriptRequest), }) } // ScriptExcutor 脚本执行器,负责制定脚本的执行逻辑,比如准备环境变量、执行脚本、处理输出等 type ScriptExcutor struct { ioc.ObjectImpl // 工作目录前缀,用于确保脚本执行在指定的目录下 WorkDirPrefix string `toml:"work_dir_prefix" json:"work_dir_prefix" yaml:"work_dir_prefix"` // 脚本目录前缀,用于确保脚本路径在指定的目录下, 避免执行不安全的脚本 ScriptDirPrefix string `toml:"script_dir_prefix" json:"script_dir_prefix" yaml:"script_dir_prefix"` // 是否启用脚本完整性校验, 通过编译时嵌入的脚本 hash 来校验脚本的完整性, 防止脚本被篡改 IsVerifyScriptIntegrity bool `toml:"is_verify_script_integrity" json:"is_verify_script_integrity" yaml:"is_verify_script_integrity"` // 日志记录器,用于记录脚本执行的日志信息 log *zerolog.Logger // 脚本完整性管理器,用于校验脚本的完整性 integrityManager *ScriptIntegrityManager // 当前正在执行中的命令列表,key为工作目录,value为执行请求 runningCommands map[string]*ExecuteScriptRequest } func (e *ScriptExcutor) Init() error { // 初始化日志记录器 e.log = log.Sub(e.Name()) // 初始化脚本完整性管理器 e.integrityManager = NewScriptIntegrityManager(e.ScriptDirPrefix, e.IsVerifyScriptIntegrity) return nil } func (e *ScriptExcutor) Name() string { return APP_NAME } // 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 进行调试") } } // 执行脚本,并且把结果写到输出到文件里面 // 开始执行命令 // 添加到运行中的命令列表中 e.runningCommands[in.workDir] = in defer delete(e.runningCommands, in.workDir) taskId := "unknown" if in.metadata != nil { taskId = in.metadata.ID } // 获取日志写入器 logWriter, err := in.getLogWriter() if err != nil { e.log.Error().Str("task_id", taskId).Err(err).Msg("创建日志写入器失败") return nil, err } defer func() { if closer, ok := logWriter.(io.Closer); ok { closer.Close() } }() // 命令的日志输出到制定的地方 in.cmd.Stdout = logWriter in.cmd.Stderr = logWriter // 记录执行开始, 包括时间、工作目录、脚本路径和元数据等信息 startTime := time.Now() logHeader := fmt.Sprintf("\n=== Execution Started ===\nTime: %s\nWorkDir: %s\nScript: %s\nMetadata: %s\n", startTime.Format("2006-01-02 15:04:05"), in.workDir, fullScriptPath, in.metadata.String(), ) fmt.Fprint(logWriter, logHeader) fmt.Fprint(logWriter, "\n") // 执行命令 e.log.Info().Str("task_id", taskId).Msg("开始执行命令") err = in.cmd.Run() endTime := time.Now() duration := endTime.Sub(startTime) // 构建执行结果 result := &ExecutionResult{ Command: fullScriptPath, StartTime: startTime, EndTime: &endTime, Duration: duration, Success: err == nil, Metadata: in.metadata, } if err != nil { // 判断是否为超时错误 if in.cmd.ProcessState != nil && in.cmd.ProcessState.ExitCode() == -1 { e.log.Error().Str("task_id", taskId).Dur("duration", duration).Dur("timeout", in.timeout).Err(err).Msg("命令执行超时") } else { e.log.Error().Str("task_id", taskId).Dur("duration", duration).Err(err).Msg("命令执行失败") } // 错误信息 result.Error = err.Error() if in.cmd.ProcessState != nil { result.ExitCode = in.cmd.ProcessState.ExitCode() } else { result.ExitCode = -1 } } else { e.log.Info().Str("task_id", taskId).Dur("duration", duration).Msg("命令执行成功") } // 解析脚本输出参数(无论成功或失败都收集) outputFile := filepath.Join(in.workDir, "output.env") if outputParams, parseErr := ParseOutputParams(outputFile); parseErr == nil && len(outputParams) > 0 { result.OutputParams = outputParams e.log.Info().Str("task_id", taskId).Bool("success", result.Success).Int("param_count", len(outputParams)).Msg("成功解析脚本输出参数") } else if parseErr != nil && !os.IsNotExist(parseErr) { e.log.Warn().Str("task_id", taskId).Str("output_file", outputFile).Err(parseErr).Msg("解析输出参数文件失败") } // 保存执行结果(无论成功或失败都保存结果,方便后续查询和调试) e.log.Debug().Str("task_id", taskId).Str("result_file", in.GetResultFilePath()).Msg("准备保存执行结果") if saveErr := in.saveResult(result); saveErr != nil { e.log.Error().Str("task_id", taskId).Err(saveErr).Msg("保存执行结果失败") } // 记录执行结束 logFooter := fmt.Sprintf("\n=== Execution Finished ===\nTime: %s\nDuration: %v\nSuccess: %t\nExitCode: %d\nError: %v\n", endTime.Format("2006-01-02 15:04:05"), duration, result.Success, result.ExitCode, err, ) fmt.Fprint(logWriter, logFooter) return result, err } // saveResult 保存执行结果到文件 func (s *ExecuteScriptRequest) saveResult(result *ExecutionResult) error { if result == nil { return nil } resultPath := s.resultFile if !filepath.IsAbs(resultPath) { resultPath = filepath.Join(s.workDir, resultPath) } // 美化输出JSON data, err := json.MarshalIndent(result, "", " ") if err != nil { return fmt.Errorf("failed to marshal result: %v", err) } if err := os.WriteFile(resultPath, data, 0644); err != nil { return fmt.Errorf("failed to save result file: %v", err) } return 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 }