executors/custom/custom.go (323 lines of code) (raw):

package custom import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "os" "path/filepath" "github.com/sirupsen/logrus" "gitlab.com/gitlab-org/gitlab-runner/common" "gitlab.com/gitlab-org/gitlab-runner/common/buildlogger" "gitlab.com/gitlab-org/gitlab-runner/executors" "gitlab.com/gitlab-org/gitlab-runner/executors/custom/api" "gitlab.com/gitlab-org/gitlab-runner/executors/custom/command" "gitlab.com/gitlab-org/gitlab-runner/helpers/featureflags" "gitlab.com/gitlab-org/gitlab-runner/helpers/process" ) type commandOutputs struct { stdout io.WriteCloser stderr io.WriteCloser } func (c *commandOutputs) Close() error { return errors.Join(c.stdout.Close(), c.stderr.Close()) } type prepareCommandOpts struct { executable string args []string out commandOutputs } type ConfigExecOutput struct { api.ConfigExecOutput } type jsonService struct { Name string `json:"name"` Alias string `json:"alias"` Entrypoint []string `json:"entrypoint"` Command []string `json:"command"` } func (c *ConfigExecOutput) InjectInto(executor *executor) { if c.Hostname != nil { executor.Build.Hostname = *c.Hostname } if c.BuildsDir != nil { executor.Config.BuildsDir = *c.BuildsDir } if c.CacheDir != nil { executor.Config.CacheDir = *c.CacheDir } if c.BuildsDirIsShared != nil { executor.SharedBuildsDir = *c.BuildsDirIsShared } executor.driverInfo = c.Driver if c.JobEnv != nil { executor.jobEnv = *c.JobEnv } if c.Shell != nil { executor.Config.Shell = *c.Shell } } type executor struct { executors.AbstractExecutor config *config tempDir string jobResponseFile string buildExitCodeFile string driverInfo *api.DriverInfo jobEnv map[string]string } func (e *executor) Prepare(options common.ExecutorPrepareOptions) error { e.AbstractExecutor.PrepareConfiguration(options) err := e.prepareConfig() if err != nil { return err } e.tempDir, err = os.MkdirTemp("", "custom-executor") if err != nil { return err } e.jobResponseFile, err = e.createJobResponseFile() if err != nil { return err } e.buildExitCodeFile = filepath.Join(e.tempDir, "build_exit_code") err = e.dynamicConfig() if err != nil { return err } e.logStartupMessage() err = e.AbstractExecutor.PrepareBuildAndShell() if err != nil { return err } // nothing to do, as there's no prepare_script if e.config.PrepareExec == "" { return nil } ctx, cancelFunc := context.WithTimeout(e.Context, e.config.GetPrepareExecTimeout()) defer cancelFunc() opts := prepareCommandOpts{ executable: e.config.PrepareExec, args: e.config.PrepareArgs, out: commandOutputs{ stdout: e.BuildLogger.Stream(buildlogger.StreamExecutorLevel, buildlogger.Stdout), stderr: e.BuildLogger.Stream(buildlogger.StreamExecutorLevel, buildlogger.Stderr), }, } defer opts.out.Close() return e.prepareCommand(ctx, opts).Run() } func (e *executor) prepareConfig() error { if e.Config.Custom == nil { return common.MakeBuildError("custom executor not configured").WithFailureReason(common.ConfigurationError) } e.config = &config{ CustomConfig: e.Config.Custom, } if e.config.RunExec == "" { return common.MakeBuildError("custom executor is missing RunExec").WithFailureReason(common.ConfigurationError) } return nil } func (e *executor) createJobResponseFile() (string, error) { responseFile := filepath.Join(e.tempDir, "response.json") file, err := os.OpenFile(responseFile, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0o600) if err != nil { return "", fmt.Errorf("creating job response file %q: %w", responseFile, err) } defer func() { _ = file.Close() }() encoder := json.NewEncoder(file) err = encoder.Encode(e.Build.JobResponse) if err != nil { return "", fmt.Errorf("encoding job response file: %w", err) } return responseFile, nil } func (e *executor) dynamicConfig() error { if e.config.ConfigExec == "" { return nil } ctx, cancelFunc := context.WithTimeout(e.Context, e.config.GetConfigExecTimeout()) defer cancelFunc() buf := bytes.NewBuffer(nil) opts := prepareCommandOpts{ executable: e.config.ConfigExec, args: e.config.ConfigArgs, out: commandOutputs{ stdout: buildlogger.NewNopCloser(buf), stderr: e.BuildLogger.Stream(buildlogger.StreamExecutorLevel, buildlogger.Stderr), }, } defer opts.out.Close() // Force refresh of all build variables for the upcoming command, ensuring // that the up-to-date environment variables are provided to the ConfigExec script. e.Build.RefreshAllVariables() err := e.prepareCommand(ctx, opts).Run() if err != nil { return err } jsonConfig := buf.Bytes() if len(jsonConfig) < 1 { return nil } config := new(ConfigExecOutput) err = json.Unmarshal(jsonConfig, config) if err != nil { return fmt.Errorf("error while parsing JSON output: %w", err) } config.InjectInto(e) return nil } func (e *executor) logStartupMessage() { const usageLine = "Using Custom executor" info := e.driverInfo if info == nil || info.Name == nil { e.BuildLogger.Println(fmt.Sprintf("%s...", usageLine)) return } if info.Version == nil { e.BuildLogger.Println(fmt.Sprintf("%s with driver %s...", usageLine, *info.Name)) return } e.BuildLogger.Println(fmt.Sprintf("%s with driver %s %s...", usageLine, *info.Name, *info.Version)) } var commandFactory = command.New func (e *executor) prepareCommand(ctx context.Context, opts prepareCommandOpts) command.Command { logger := common.NewProcessLoggerAdapter(e.BuildLogger) cmdOpts := process.CommandOptions{ Dir: e.tempDir, Env: make([]string, 0), Stdout: opts.out.stdout, Stderr: opts.out.stderr, Logger: logger, GracefulKillTimeout: e.config.GetGracefulKillTimeout(), ForceKillTimeout: e.config.GetForceKillTimeout(), UseWindowsLegacyProcessStrategy: e.Build.IsFeatureFlagOn(featureflags.UseWindowsLegacyProcessStrategy), UseWindowsJobObject: e.Build.IsFeatureFlagOn(featureflags.UseWindowsJobObject), } // Append job_env defined variable first to avoid overwriting any CI/CD or predefined variables. for k, v := range e.jobEnv { cmdOpts.Env = append(cmdOpts.Env, fmt.Sprintf("%s=%s", k, v)) } variables := append(e.Build.GetAllVariables(), e.getCIJobServicesEnv()) for _, variable := range variables { cmdOpts.Env = append(cmdOpts.Env, fmt.Sprintf("CUSTOM_ENV_%s=%s", variable.Key, variable.Value)) } options := command.Options{ JobResponseFile: e.jobResponseFile, BuildExitCodeFile: e.buildExitCodeFile, } return commandFactory(ctx, opts.executable, opts.args, cmdOpts, options) } func (e *executor) getCIJobServicesEnv() common.JobVariable { if len(e.Build.Services) == 0 { return common.JobVariable{Key: "CI_JOB_SERVICES"} } var services []jsonService for _, service := range e.Build.Services { services = append(services, jsonService{ Name: service.Name, Alias: append(service.Aliases(), "")[0], Entrypoint: service.Entrypoint, Command: service.Command, }) } servicesSerialized, err := json.Marshal(services) if err != nil { e.BuildLogger.Warningln("Unable to serialize CI_JOB_SERVICES json:", err) } return common.JobVariable{ Key: "CI_JOB_SERVICES", Value: string(servicesSerialized), } } func (e *executor) Run(cmd common.ExecutorCommand) error { scriptDir, err := os.MkdirTemp(e.tempDir, "script") if err != nil { return err } scriptName := "script" if e.BuildShell.Extension != "" { scriptName += "." + e.BuildShell.Extension } scriptFile := filepath.Join(scriptDir, scriptName) err = os.WriteFile(scriptFile, []byte(cmd.Script), 0o700) if err != nil { return err } // TODO: Remove this translation - https://gitlab.com/groups/gitlab-org/-/epics/6112 stage := cmd.Stage if stage == "step_script" { e.BuildLogger.Warningln("Starting with version 17.0 the 'build_script' stage " + "will be replaced with 'step_script': https://gitlab.com/groups/gitlab-org/-/epics/6112") stage = "build_script" } args := append(e.config.RunArgs, scriptFile, string(stage)) //nolint:gocritic opts := prepareCommandOpts{ executable: e.config.RunExec, args: args, out: commandOutputs{ stdout: e.BuildLogger.Stream(buildlogger.StreamWorkLevel, buildlogger.Stdout), stderr: e.BuildLogger.Stream(buildlogger.StreamWorkLevel, buildlogger.Stderr), }, } defer opts.out.Close() return e.prepareCommand(cmd.Context, opts).Run() } func (e *executor) Cleanup() { e.AbstractExecutor.Cleanup() err := e.prepareConfig() if err != nil { e.BuildLogger.Warningln(err) // at this moment we don't care about the errors return } defer func() { _ = os.RemoveAll(e.tempDir) }() // nothing to do, as there's no cleanup_script if e.config.CleanupExec == "" { return } ctx, cancelFunc := context.WithTimeout(context.Background(), e.config.GetCleanupScriptTimeout()) defer cancelFunc() stdoutLogger := e.BuildLogger.WithFields(logrus.Fields{"cleanup_std": "out"}) stderrLogger := e.BuildLogger.WithFields(logrus.Fields{"cleanup_std": "err"}) opts := prepareCommandOpts{ executable: e.config.CleanupExec, args: e.config.CleanupArgs, out: commandOutputs{ stdout: stdoutLogger.WriterLevel(logrus.DebugLevel), stderr: stderrLogger.WriterLevel(logrus.WarnLevel), }, } defer opts.out.Close() err = e.prepareCommand(ctx, opts).Run() if err != nil { e.BuildLogger.Warningln("Cleanup script failed:", err) } } func init() { RegisterExecutor("custom", "gitlab-runner") } func RegisterExecutor(executorName string, runnerCommandPath string) { options := executors.ExecutorOptions{ DefaultCustomBuildsDirEnabled: false, DefaultSafeDirectoryCheckout: false, Shell: common.ShellScriptInfo{ Shell: common.GetDefaultShell(), Type: common.NormalShell, RunnerCommand: runnerCommandPath, }, ShowHostname: false, } creator := func() common.Executor { return &executor{ AbstractExecutor: executors.AbstractExecutor{ ExecutorOptions: options, }, } } featuresUpdater := func(features *common.FeaturesInfo) { features.Variables = true features.Shared = true } common.RegisterExecutorProvider(executorName, executors.DefaultExecutorProvider{ Creator: creator, FeaturesUpdater: featuresUpdater, DefaultShellName: options.Shell.Shell, }) }