package container import ( "archive/tar" "bufio" "bytes" "context" "fmt" "io" "io/ioutil" "os" "path/filepath" "regexp" "runtime" "strings" "github.com/go-git/go-billy/v5/helper/polyfill" "github.com/go-git/go-billy/v5/osfs" "github.com/go-git/go-git/v5/plumbing/format/gitignore" "github.com/joho/godotenv" "github.com/docker/cli/cli/connhelper" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" specs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/Masterminds/semver" "golang.org/x/term" "github.com/nektos/act/pkg/common" ) // NewContainerInput the input for the New function type NewContainerInput struct { Image string Username string Password string Entrypoint []string Cmd []string WorkingDir string Env []string Binds []string Mounts map[string]string Name string Stdout io.Writer Stderr io.Writer NetworkMode string Privileged bool UsernsMode string Platform string Hostname string } // FileEntry is a file to copy to a container type FileEntry struct { Name string Mode int64 Body string } // Container for managing docker run containers type Container interface { Create(capAdd []string, capDrop []string) common.Executor Copy(destPath string, files ...*FileEntry) common.Executor CopyDir(destPath string, srcPath string, useGitIgnore bool) common.Executor GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) Pull(forcePull bool) common.Executor Start(attach bool) common.Executor Exec(command []string, env map[string]string, user, workdir string) common.Executor UpdateFromEnv(srcPath string, env *map[string]string) common.Executor UpdateFromImageEnv(env *map[string]string) common.Executor UpdateFromPath(env *map[string]string) common.Executor Remove() common.Executor Close() common.Executor ReplaceLogWriter(io.Writer, io.Writer) (io.Writer, io.Writer) } // NewContainer creates a reference to a container func NewContainer(input *NewContainerInput) Container { cr := new(containerReference) cr.input = input return cr } // supportsContainerImagePlatform returns true if the underlying Docker server // API version is 1.41 and beyond func supportsContainerImagePlatform(ctx context.Context, cli client.APIClient) bool { logger := common.Logger(ctx) ver, err := cli.ServerVersion(ctx) if err != nil { logger.Panicf("Failed to get Docker API Version: %s", err) return false } sv, err := semver.NewVersion(ver.APIVersion) if err != nil { logger.Panicf("Failed to unmarshal Docker Version: %s", err) return false } constraint, _ := semver.NewConstraint(">= 1.41") return constraint.Check(sv) } func (cr *containerReference) Create(capAdd []string, capDrop []string) common.Executor { return common. NewInfoExecutor("%sdocker create image=%s platform=%s entrypoint=%+q cmd=%+q", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd). Then( common.NewPipelineExecutor( cr.connect(), cr.find(), cr.create(capAdd, capDrop), ).IfNot(common.Dryrun), ) } func (cr *containerReference) Start(attach bool) common.Executor { return common. NewInfoExecutor("%sdocker run image=%s platform=%s entrypoint=%+q cmd=%+q", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Entrypoint, cr.input.Cmd). Then( common.NewPipelineExecutor( cr.connect(), cr.find(), cr.attach().IfBool(attach), cr.start(), cr.wait().IfBool(attach), ).IfNot(common.Dryrun), ) } func (cr *containerReference) Pull(forcePull bool) common.Executor { return common. NewInfoExecutor("%sdocker pull image=%s platform=%s username=%s forcePull=%t", logPrefix, cr.input.Image, cr.input.Platform, cr.input.Username, forcePull). Then( NewDockerPullExecutor(NewDockerPullExecutorInput{ Image: cr.input.Image, ForcePull: forcePull, Platform: cr.input.Platform, Username: cr.input.Username, Password: cr.input.Password, }), ) } func (cr *containerReference) Copy(destPath string, files ...*FileEntry) common.Executor { return common.NewPipelineExecutor( cr.connect(), cr.find(), cr.copyContent(destPath, files...), ).IfNot(common.Dryrun) } func (cr *containerReference) CopyDir(destPath string, srcPath string, useGitIgnore bool) common.Executor { return common.NewPipelineExecutor( common.NewInfoExecutor("%sdocker cp src=%s dst=%s", logPrefix, srcPath, destPath), cr.Exec([]string{"mkdir", "-p", destPath}, nil, "", ""), cr.copyDir(destPath, srcPath, useGitIgnore), ).IfNot(common.Dryrun) } func (cr *containerReference) GetContainerArchive(ctx context.Context, srcPath string) (io.ReadCloser, error) { if common.Dryrun(ctx) { return nil, fmt.Errorf("DRYRUN is not supported in GetContainerArchive") } a, _, err := cr.cli.CopyFromContainer(ctx, cr.id, srcPath) return a, err } func (cr *containerReference) UpdateFromEnv(srcPath string, env *map[string]string) common.Executor { return cr.extractEnv(srcPath, env).IfNot(common.Dryrun) } func (cr *containerReference) UpdateFromImageEnv(env *map[string]string) common.Executor { return cr.extractFromImageEnv(env).IfNot(common.Dryrun) } func (cr *containerReference) UpdateFromPath(env *map[string]string) common.Executor { return cr.extractPath(env).IfNot(common.Dryrun) } func (cr *containerReference) Exec(command []string, env map[string]string, user, workdir string) common.Executor { return common.NewPipelineExecutor( common.NewInfoExecutor("%sdocker exec cmd=[%s] user=%s workdir=%s", logPrefix, strings.Join(command, " "), user, workdir), cr.connect(), cr.find(), cr.exec(command, env, user, workdir), ).IfNot(common.Dryrun) } func (cr *containerReference) Remove() common.Executor { return common.NewPipelineExecutor( cr.connect(), cr.find(), ).Finally( cr.remove(), ).IfNot(common.Dryrun) } func (cr *containerReference) ReplaceLogWriter(stdout io.Writer, stderr io.Writer) (io.Writer, io.Writer) { out := cr.input.Stdout err := cr.input.Stderr cr.input.Stdout = stdout cr.input.Stderr = stderr return out, err } type containerReference struct { cli client.APIClient id string input *NewContainerInput } func GetDockerClient(ctx context.Context) (cli client.APIClient, err error) { // TODO: this should maybe need to be a global option, not hidden in here? // though i'm not sure how that works out when there's another Executor :D // I really would like something that works on OSX native for eg dockerHost := os.Getenv("DOCKER_HOST") if strings.HasPrefix(dockerHost, "ssh://") { var helper *connhelper.ConnectionHelper helper, err = connhelper.GetConnectionHelper(dockerHost) if err != nil { return nil, err } cli, err = client.NewClientWithOpts( client.WithHost(helper.Host), client.WithDialContext(helper.Dialer), ) } else { cli, err = client.NewClientWithOpts(client.FromEnv) } if err != nil { return nil, fmt.Errorf("failed to connect to docker daemon: %w", err) } cli.NegotiateAPIVersion(ctx) return cli, nil } func GetHostInfo(ctx context.Context) (info types.Info, err error) { var cli client.APIClient cli, err = GetDockerClient(ctx) if err != nil { return info, err } defer cli.Close() info, err = cli.Info(ctx) if err != nil { return info, err } return info, nil } func (cr *containerReference) connect() common.Executor { return func(ctx context.Context) error { if cr.cli != nil { return nil } cli, err := GetDockerClient(ctx) if err != nil { return err } cr.cli = cli return nil } } func (cr *containerReference) Close() common.Executor { return func(ctx context.Context) error { if cr.cli != nil { err := cr.cli.Close() cr.cli = nil if err != nil { return fmt.Errorf("failed to close client: %w", err) } } return nil } } func (cr *containerReference) find() common.Executor { return func(ctx context.Context) error { if cr.id != "" { return nil } containers, err := cr.cli.ContainerList(ctx, types.ContainerListOptions{ All: true, }) if err != nil { return fmt.Errorf("failed to list containers: %w", err) } for _, c := range containers { for _, name := range c.Names { if name[1:] == cr.input.Name { cr.id = c.ID return nil } } } cr.id = "" return nil } } func (cr *containerReference) remove() common.Executor { return func(ctx context.Context) error { if cr.id == "" { return nil } logger := common.Logger(ctx) err := cr.cli.ContainerRemove(ctx, cr.id, types.ContainerRemoveOptions{ RemoveVolumes: true, Force: true, }) if err != nil { logger.Error(fmt.Errorf("failed to remove container: %w", err)) } logger.Debugf("Removed container: %v", cr.id) cr.id = "" return nil } } func (cr *containerReference) create(capAdd []string, capDrop []string) common.Executor { return func(ctx context.Context) error { if cr.id != "" { return nil } logger := common.Logger(ctx) isTerminal := term.IsTerminal(int(os.Stdout.Fd())) input := cr.input config := &container.Config{ Image: input.Image, WorkingDir: input.WorkingDir, Env: input.Env, Tty: isTerminal, Hostname: input.Hostname, } if len(input.Cmd) != 0 { config.Cmd = input.Cmd } if len(input.Entrypoint) != 0 { config.Entrypoint = input.Entrypoint } mounts := make([]mount.Mount, 0) for mountSource, mountTarget := range input.Mounts { mounts = append(mounts, mount.Mount{ Type: mount.TypeVolume, Source: mountSource, Target: mountTarget, }) } var platSpecs *specs.Platform if supportsContainerImagePlatform(ctx, cr.cli) && cr.input.Platform != "" { desiredPlatform := strings.SplitN(cr.input.Platform, `/`, 2) if len(desiredPlatform) != 2 { return fmt.Errorf("incorrect container platform option '%s'", cr.input.Platform) } platSpecs = &specs.Platform{ Architecture: desiredPlatform[1], OS: desiredPlatform[0], } } resp, err := cr.cli.ContainerCreate(ctx, config, &container.HostConfig{ CapAdd: capAdd, CapDrop: capDrop, Binds: input.Binds, Mounts: mounts, NetworkMode: container.NetworkMode(input.NetworkMode), Privileged: input.Privileged, UsernsMode: container.UsernsMode(input.UsernsMode), }, nil, platSpecs, input.Name) if err != nil { return fmt.Errorf("failed to create container: %w", err) } logger.Debugf("Created container name=%s id=%v from image %v (platform: %s)", input.Name, resp.ID, input.Image, input.Platform) logger.Debugf("ENV ==> %v", input.Env) cr.id = resp.ID return nil } } var singleLineEnvPattern, multiLineEnvPattern *regexp.Regexp func (cr *containerReference) extractEnv(srcPath string, env *map[string]string) common.Executor { if singleLineEnvPattern == nil { // Single line pattern matches: // SOME_VAR=data=moredata // SOME_VAR=datamoredata singleLineEnvPattern = regexp.MustCompile(`^([^=]*)\=(.*)$`) multiLineEnvPattern = regexp.MustCompile(`^([^<]+)<<(\w+)$`) } localEnv := *env return func(ctx context.Context) error { envTar, _, err := cr.cli.CopyFromContainer(ctx, cr.id, srcPath) if err != nil { return nil } defer envTar.Close() reader := tar.NewReader(envTar) _, err = reader.Next() if err != nil && err != io.EOF { return fmt.Errorf("failed to read tar archive: %w", err) } s := bufio.NewScanner(reader) multiLineEnvKey := "" multiLineEnvDelimiter := "" multiLineEnvContent := "" for s.Scan() { line := s.Text() if singleLineEnv := singleLineEnvPattern.FindStringSubmatch(line); singleLineEnv != nil { localEnv[singleLineEnv[1]] = singleLineEnv[2] } if line == multiLineEnvDelimiter { localEnv[multiLineEnvKey] = multiLineEnvContent multiLineEnvKey, multiLineEnvDelimiter, multiLineEnvContent = "", "", "" } if multiLineEnvKey != "" && multiLineEnvDelimiter != "" { if multiLineEnvContent != "" { multiLineEnvContent += "\n" } multiLineEnvContent += line } if multiLineEnvStart := multiLineEnvPattern.FindStringSubmatch(line); multiLineEnvStart != nil { multiLineEnvKey = multiLineEnvStart[1] multiLineEnvDelimiter = multiLineEnvStart[2] } } env = &localEnv return nil } } func (cr *containerReference) extractFromImageEnv(env *map[string]string) common.Executor { envMap := *env return func(ctx context.Context) error { logger := common.Logger(ctx) inspect, _, err := cr.cli.ImageInspectWithRaw(ctx, cr.input.Image) if err != nil { logger.Error(err) } imageEnv, err := godotenv.Unmarshal(strings.Join(inspect.Config.Env, "\n")) if err != nil { logger.Error(err) } for k, v := range imageEnv { if k == "PATH" { if envMap[k] == "" { envMap[k] = v } else { envMap[k] += `:` + v } } else if envMap[k] == "" { envMap[k] = v } } env = &envMap return nil } } func (cr *containerReference) extractPath(env *map[string]string) common.Executor { localEnv := *env return func(ctx context.Context) error { pathTar, _, err := cr.cli.CopyFromContainer(ctx, cr.id, localEnv["GITHUB_PATH"]) if err != nil { return fmt.Errorf("failed to copy from container: %w", err) } defer pathTar.Close() reader := tar.NewReader(pathTar) _, err = reader.Next() if err != nil && err != io.EOF { return fmt.Errorf("failed to read tar archive: %w", err) } s := bufio.NewScanner(reader) for s.Scan() { line := s.Text() localEnv["PATH"] = fmt.Sprintf("%s:%s", line, localEnv["PATH"]) } env = &localEnv return nil } } func (cr *containerReference) exec(cmd []string, env map[string]string, user, workdir string) common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) // Fix slashes when running on Windows if runtime.GOOS == "windows" { var newCmd []string for _, v := range cmd { newCmd = append(newCmd, strings.ReplaceAll(v, `\`, `/`)) } cmd = newCmd } logger.Debugf("Exec command '%s'", cmd) isTerminal := term.IsTerminal(int(os.Stdout.Fd())) envList := make([]string, 0) for k, v := range env { envList = append(envList, fmt.Sprintf("%s=%s", k, v)) } var wd string if workdir != "" { if strings.HasPrefix(workdir, "/") { wd = workdir } else { wd = fmt.Sprintf("%s/%s", cr.input.WorkingDir, workdir) } } else { wd = cr.input.WorkingDir } logger.Debugf("Working directory '%s'", wd) idResp, err := cr.cli.ContainerExecCreate(ctx, cr.id, types.ExecConfig{ User: user, Cmd: cmd, WorkingDir: wd, Env: envList, Tty: isTerminal, AttachStderr: true, AttachStdout: true, }) if err != nil { return fmt.Errorf("failed to create exec: %w", err) } resp, err := cr.cli.ContainerExecAttach(ctx, idResp.ID, types.ExecStartCheck{ Tty: isTerminal, }) if err != nil { return fmt.Errorf("failed to attach to exec: %w", err) } defer resp.Close() err = cr.waitForCommand(ctx, isTerminal, resp, idResp, user, workdir) if err != nil { return err } inspectResp, err := cr.cli.ContainerExecInspect(ctx, idResp.ID) if err != nil { return fmt.Errorf("failed to inspect exec: %w", err) } switch inspectResp.ExitCode { case 0: return nil case 127: return fmt.Errorf("exitcode '%d': command not found, please refer to https://github.com/nektos/act/issues/107 for more information", inspectResp.ExitCode) default: return fmt.Errorf("exitcode '%d': failure", inspectResp.ExitCode) } } } func (cr *containerReference) waitForCommand(ctx context.Context, isTerminal bool, resp types.HijackedResponse, idResp types.IDResponse, user string, workdir string) error { logger := common.Logger(ctx) cmdResponse := make(chan error) go func() { var outWriter io.Writer outWriter = cr.input.Stdout if outWriter == nil { outWriter = os.Stdout } errWriter := cr.input.Stderr if errWriter == nil { errWriter = os.Stderr } var err error if !isTerminal || os.Getenv("NORAW") != "" { _, err = stdcopy.StdCopy(outWriter, errWriter, resp.Reader) } else { _, err = io.Copy(outWriter, resp.Reader) } cmdResponse <- err }() select { case <-ctx.Done(): // send ctrl + c _, err := resp.Conn.Write([]byte{3}) if err != nil { logger.Warnf("Failed to send CTRL+C: %+s", err) } // we return the context canceled error to prevent other steps // from executing return ctx.Err() case err := <-cmdResponse: if err != nil { logger.Error(err) } return nil } } func (cr *containerReference) copyDir(dstPath string, srcPath string, useGitIgnore bool) common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) tarFile, err := ioutil.TempFile("", "act") if err != nil { return err } logger.Debugf("Writing tarball %s from %s", tarFile.Name(), srcPath) defer func(tarFile *os.File) { name := tarFile.Name() err := tarFile.Close() if err != nil { logger.Error(err) } err = os.Remove(name) if err != nil { logger.Error(err) } }(tarFile) tw := tar.NewWriter(tarFile) srcPrefix := filepath.Dir(srcPath) if !strings.HasSuffix(srcPrefix, string(filepath.Separator)) { srcPrefix += string(filepath.Separator) } logger.Debugf("Stripping prefix:%s src:%s", srcPrefix, srcPath) var ignorer gitignore.Matcher if useGitIgnore { ps, err := gitignore.ReadPatterns(polyfill.New(osfs.New(srcPath)), nil) if err != nil { logger.Debugf("Error loading .gitignore: %v", err) } ignorer = gitignore.NewMatcher(ps) } fc := &fileCollector{ Fs: &defaultFs{}, Ignorer: ignorer, SrcPath: srcPath, SrcPrefix: srcPrefix, Handler: &tarCollector{ TarWriter: tw, }, } err = filepath.Walk(srcPath, fc.collectFiles(ctx, []string{})) if err != nil { return err } if err := tw.Close(); err != nil { return err } logger.Debugf("Extracting content from '%s' to '%s'", tarFile.Name(), dstPath) _, err = tarFile.Seek(0, 0) if err != nil { return fmt.Errorf("failed to seek tar archive: %w", err) } err = cr.cli.CopyToContainer(ctx, cr.id, dstPath, tarFile, types.CopyToContainerOptions{}) if err != nil { return fmt.Errorf("failed to copy content to container: %w", err) } return nil } } func (cr *containerReference) copyContent(dstPath string, files ...*FileEntry) common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) var buf bytes.Buffer tw := tar.NewWriter(&buf) for _, file := range files { logger.Debugf("Writing entry to tarball %s len:%d", file.Name, len(file.Body)) hdr := &tar.Header{ Name: file.Name, Mode: file.Mode, Size: int64(len(file.Body)), } if err := tw.WriteHeader(hdr); err != nil { return err } if _, err := tw.Write([]byte(file.Body)); err != nil { return err } } if err := tw.Close(); err != nil { return err } logger.Debugf("Extracting content to '%s'", dstPath) err := cr.cli.CopyToContainer(ctx, cr.id, dstPath, &buf, types.CopyToContainerOptions{}) if err != nil { return fmt.Errorf("failed to copy content to container: %w", err) } return nil } } func (cr *containerReference) attach() common.Executor { return func(ctx context.Context) error { out, err := cr.cli.ContainerAttach(ctx, cr.id, types.ContainerAttachOptions{ Stream: true, Stdout: true, Stderr: true, }) if err != nil { return fmt.Errorf("failed to attach to container: %w", err) } isTerminal := term.IsTerminal(int(os.Stdout.Fd())) var outWriter io.Writer outWriter = cr.input.Stdout if outWriter == nil { outWriter = os.Stdout } errWriter := cr.input.Stderr if errWriter == nil { errWriter = os.Stderr } go func() { if !isTerminal || os.Getenv("NORAW") != "" { _, err = stdcopy.StdCopy(outWriter, errWriter, out.Reader) } else { _, err = io.Copy(outWriter, out.Reader) } if err != nil { common.Logger(ctx).Error(err) } }() return nil } } func (cr *containerReference) start() common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) logger.Debugf("Starting container: %v", cr.id) if err := cr.cli.ContainerStart(ctx, cr.id, types.ContainerStartOptions{}); err != nil { return fmt.Errorf("failed to start container: %w", err) } logger.Debugf("Started container: %v", cr.id) return nil } } func (cr *containerReference) wait() common.Executor { return func(ctx context.Context) error { logger := common.Logger(ctx) statusCh, errCh := cr.cli.ContainerWait(ctx, cr.id, container.WaitConditionNotRunning) var statusCode int64 select { case err := <-errCh: if err != nil { return fmt.Errorf("failed to wait for container: %w", err) } case status := <-statusCh: statusCode = status.StatusCode } logger.Debugf("Return status: %v", statusCode) if statusCode == 0 { return nil } return fmt.Errorf("exit with `FAILURE`: %v", statusCode) } }