Files
dokku/plugins/common/docker.go
Jose Diaz-Gonzalez 6ce10b5be6 fix: ensure compose projects are spawned from the /tmp directory
A recent update to compose executes a stat call in the current working directory, which may have incorrect permissions for execution once the user is changed to the dokku user. This change forces all compose commands to execute in the /tmp directory by using a helper function to execute compose up/down.

Closes #7705
2025-05-24 23:31:41 -04:00

601 lines
15 KiB
Go

package common
import (
"archive/tar"
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
)
// ComposeUpInput is the input for the ComposeUp function
type ComposeUpInput struct {
ProjectName string
ComposeFile string
}
// ComposeUp executes a docker compose up command
func ComposeUp(input ComposeUpInput) error {
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{
"compose",
"--file", input.ComposeFile,
"--project-name", input.ProjectName,
"up",
"--detach",
"--quiet-pull",
},
StreamStdio: true,
WorkingDirectory: "/tmp",
})
if err != nil || result.ExitCode != 0 {
return fmt.Errorf("Unable to start compose project: %s", result.Stderr)
}
return nil
}
// ComposeDownInput is the input for the ComposeDown function
type ComposeDownInput struct {
ProjectName string
ComposeFile string
}
// ComposeDown executes a docker compose down command
func ComposeDown(input ComposeDownInput) error {
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{
"compose",
"--file", input.ComposeFile,
"--project-name", input.ProjectName,
"down",
"--remove-orphans",
},
StreamStdio: true,
WorkingDirectory: "/tmp",
})
if err != nil || result.ExitCode != 0 {
return fmt.Errorf("Unable to stop %s: %s", input.ProjectName, result.Stderr)
}
return nil
}
// ContainerIsRunning checks to see if a container is running
func ContainerIsRunning(containerID string) bool {
b, err := DockerInspect(containerID, "'{{.State.Running}}'")
if err != nil {
return false
}
return strings.TrimSpace(string(b[:])) == "true"
}
// ContainerStart runs 'docker container start' against an existing container
func ContainerStart(containerID string) bool {
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{"container", "start", containerID},
StreamStderr: true,
})
if err != nil {
return false
}
return result.ExitCode == 0
}
// ContainerRemove runs 'docker container remove' against an existing container
func ContainerRemove(containerID string) bool {
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{"container", "remove", "-f", containerID},
StreamStderr: true,
})
if err != nil {
return false
}
return result.ExitCode == 0
}
// ContainerExists checks to see if a container exists
func ContainerExists(containerID string) bool {
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{"container", "inspect", containerID},
})
if err != nil {
return false
}
return result.ExitCode == 0
}
// ContainerWait runs 'docker container wait' against an existing container
func ContainerWait(containerID string) bool {
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{"container", "wait", containerID},
StreamStderr: true,
})
if err != nil {
return false
}
stdout := result.StdoutContents()
if stdout != "0" {
exitCode, err := strconv.Atoi(stdout)
if err != nil {
return false
}
return exitCode == 0
}
return result.ExitCode == 0
}
// ContainerWaitTilReady will wait timeout seconds and then check if a container is running
// returning an error if it is not running at the end of the timeout
func ContainerWaitTilReady(containerID string, timeout time.Duration) error {
time.Sleep(timeout)
if !ContainerIsRunning(containerID) {
return fmt.Errorf("Container %s is not running", containerID)
}
return nil
}
// CopyFromImage copies a file from named image to destination
func CopyFromImage(appName string, image string, source string, destination string) error {
if !VerifyImage(image) {
return fmt.Errorf("Invalid docker image for copying content")
}
if !IsAbsPath(source) {
workDir := GetWorkingDir(appName, image)
if workDir != "" {
source = fmt.Sprintf("%s/%s", workDir, source)
}
}
globalRunArgs := MustGetEnv("DOKKU_GLOBAL_RUN_ARGS")
createLabelArgs := []string{"--label", fmt.Sprintf("com.dokku.app-name=%s", appName), globalRunArgs}
containerID, err := DockerContainerCreate(image, createLabelArgs)
if err != nil {
return fmt.Errorf("Unable to create temporary container: %v", err)
}
defer ContainerRemove(containerID)
// docker cp exits with status 1 when run as non-root user when it tries to chown the file
// after successfully copying the file. Thus, we suppress stderr.
// ref: https://github.com/dotcloud/docker/issues/3986
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{"container", "cp", "--quiet", fmt.Sprintf("%s:%s", containerID, source), "-"},
})
if err != nil {
return fmt.Errorf("Unable to copy file %s from image: %w", source, err)
}
if result.ExitCode != 0 {
return fmt.Errorf("Unable to copy file %s from image: %v", source, result.StderrContents())
}
tarContents := result.StdoutContents()
if tarContents == "" {
return fmt.Errorf("Unable to copy file %s from image", source)
}
// extract the contents via tar
content, err := extractTarToString(tarContents)
if err != nil {
return fmt.Errorf("Unable to extract contents from tar: %v", err)
}
tmpFile, err := os.CreateTemp(os.TempDir(), fmt.Sprintf("dokku-%s-%s", MustGetEnv("DOKKU_PID"), "CopyFromImage"))
if err != nil {
return fmt.Errorf("Cannot create temporary file: %v", err)
}
defer func() {
if err := tmpFile.Close(); err != nil {
LogWarn(fmt.Sprintf("Unable to close temporary file: %v", err))
}
if err := os.Remove(tmpFile.Name()); err != nil {
LogWarn(fmt.Sprintf("Unable to remove temporary file: %v", err))
}
}()
// write contents to tmpFile
if _, err := tmpFile.Write([]byte(content)); err != nil {
return fmt.Errorf("Unable to write to temporary file: %v", err)
}
fi, err := os.Stat(tmpFile.Name())
if err != nil {
return err
}
if fi.Size() == 0 {
return fmt.Errorf("Unable to copy file %s from image", source)
}
// workaround when owner is root. seems to only happen when running inside docker
CallExecCommand(ExecCommandInput{
Command: "dos2unix",
Args: []string{"-l", "-n", tmpFile.Name(), destination},
}) // nolint: errcheck
// add trailing newline for certain places where file parsing depends on it
result, err = CallExecCommand(ExecCommandInput{
Command: "tail",
Args: []string{"-c1", destination},
})
if err != nil || result.ExitCode != 0 {
return fmt.Errorf("Unable to append trailing newline to copied file: %v", result.Stderr)
}
if result.Stdout != "" {
f, err := os.OpenFile(destination, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString("\n"); err != nil {
return fmt.Errorf("Unable to append trailing newline to copied file: %v", err)
}
}
return nil
}
// Function to extract tar contents and return them as a string
func extractTarToString(in string) (string, error) {
// Initialize a buffer to accumulate the extracted content
var extractedContent bytes.Buffer
// Create a tar reader from standard input
tarReader := tar.NewReader(strings.NewReader(in))
// Iterate through the files in the tar archive
for {
// Read the next header (file entry)
_, err := tarReader.Next()
if err == io.EOF {
break // End of archive
}
if err != nil {
return "", fmt.Errorf("error reading tar header: %v", err)
}
// Write the content of the current file into the buffer
_, err = io.Copy(&extractedContent, tarReader)
if err != nil {
return "", fmt.Errorf("error copying file content: %v", err)
}
}
// Return the accumulated content as a string
return strings.TrimSpace(extractedContent.String()), nil
}
// DockerBin returns a string which contains a path to the current docker binary
func DockerBin() string {
dockerBin := os.Getenv("DOCKER_BIN")
if dockerBin == "" {
dockerBin = "docker"
}
return dockerBin
}
// DockerCleanup cleans up all exited/dead containers and removes all dangling images
func DockerCleanup(appName string, forceCleanup bool) error {
if !forceCleanup {
skipCleanup := false
if appName != "" {
triggerName := "config-get"
triggerArgs := []string{appName, "DOKKU_SKIP_CLEANUP"}
if appName == "--global" {
triggerName = "config-get-global"
triggerArgs = []string{"DOKKU_SKIP_CLEANUP"}
}
results, _ := CallPlugnTrigger(PlugnTriggerInput{
Trigger: triggerName,
Args: triggerArgs,
})
if results.StdoutContents() == "true" {
skipCleanup = true
}
}
if skipCleanup || os.Getenv("DOKKU_SKIP_CLEANUP") == "true" {
LogInfo1("DOKKU_SKIP_CLEANUP set. Skipping dokku cleanup")
return nil
}
}
LogInfo1("Cleaning up...")
if appName == "--global" {
appName = ""
}
// delete all non-running and dead containers
exitedContainerIDs, _ := listContainers("exited", appName)
deadContainerIDs, _ := listContainers("dead", appName)
containerIDs := append(exitedContainerIDs, deadContainerIDs...)
if len(containerIDs) > 0 {
DockerRemoveContainers(containerIDs)
}
// delete dangling images
imageIDs, _ := ListDanglingImages(appName)
if len(imageIDs) > 0 {
RemoveImages(imageIDs)
}
if appName != "" {
// delete unused images
pruneUnusedImages(appName)
}
return nil
}
// DockerContainerCreate creates a new container and returns the container ID
func DockerContainerCreate(image string, containerCreateArgs []string) (string, error) {
args := []string{
"container",
"create",
}
args = append(args, containerCreateArgs...)
args = append(args, image)
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: args,
})
if err != nil {
return "", fmt.Errorf("Unable to create container: %w", err)
}
if result.ExitCode != 0 {
return "", fmt.Errorf("Unable to create container: %s", result.StderrContents())
}
return result.StdoutContents(), nil
}
// DockerInspect runs an inspect command with a given format against a container or image ID
func DockerInspect(containerOrImageID, format string) (output string, err error) {
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{"inspect", "--format", format, containerOrImageID},
})
if err != nil {
return "", err
}
output = result.StdoutContents()
if strings.HasPrefix(output, "'") && strings.HasSuffix(output, "'") {
output = strings.TrimSuffix(strings.TrimPrefix(output, "'"), "'")
}
return
}
// GetWorkingDir returns the working directory for a given image
func GetWorkingDir(appName string, image string) string {
if IsImageCnbBased(image) {
return "/workspace"
} else if IsImageHerokuishBased(image, appName) {
return "/app"
}
workDir, _ := DockerInspect(image, "{{.Config.WorkingDir}}")
return workDir
}
func IsComposeInstalled() bool {
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{"info", "--format", "{{range .ClientInfo.Plugins}}{{if eq .Name \"compose\"}}true{{end}}{{end}}')"},
})
return err == nil && result.ExitCode == 0
}
// IsImageCnbBased returns true if app image is based on cnb
func IsImageCnbBased(image string) bool {
if len(image) == 0 {
return false
}
output, err := DockerInspect(image, "{{index .Config.Labels \"io.buildpacks.stack.id\" }}")
if err != nil {
return false
}
return output != ""
}
// IsImageHerokuishBased returns true if app image is based on herokuish
func IsImageHerokuishBased(image string, appName string) bool {
if len(image) == 0 {
return false
}
if IsImageCnbBased(image) {
return true
}
dokkuAppUser := ""
if len(appName) != 0 {
results, err := CallPlugnTrigger(PlugnTriggerInput{
Trigger: "config-get",
Args: []string{appName, "DOKKU_APP_USER"},
})
if err == nil {
dokkuAppUser = results.StdoutContents()
}
}
if len(dokkuAppUser) == 0 {
dokkuAppUser = "herokuishuser"
}
output, err := DockerInspect(image, fmt.Sprintf("{{range .Config.Env}}{{if eq . \"USER=%s\" }}{{println .}}{{end}}{{end}}", dokkuAppUser))
if err != nil {
return false
}
return output != ""
}
// ListDanglingImages lists all dangling image ids for a given app
func ListDanglingImages(appName string) ([]string, error) {
filters := []string{"dangling=true"}
if appName != "" {
filters = append(filters, []string{fmt.Sprintf("label=com.dokku.app-name=%v", appName)}...)
}
return DockerFilterImages(filters)
}
// RemoveImages removes images by ID
func RemoveImages(imageIDs []string) error {
if len(imageIDs) == 0 {
return nil
}
args := []string{
"image",
"rm",
}
args = append(args, imageIDs...)
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: args,
})
if err != nil {
return fmt.Errorf("Unable to remove images: %w", err)
}
if result.ExitCode != 0 {
return fmt.Errorf("Unable to remove images: %s", result.StderrContents())
}
return nil
}
// VerifyImage returns true if docker image exists in local repo
func VerifyImage(image string) bool {
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: []string{"image", "inspect", image},
})
return err == nil && result.ExitCode == 0
}
// DockerFilterContainers returns a slice of container IDs based on the passed in filters
func DockerFilterContainers(filters []string) ([]string, error) {
args := []string{
"container",
"ls",
"--quiet",
"--all",
}
for _, filter := range filters {
args = append(args, "--filter", filter)
}
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: args,
})
if err != nil {
return []string{}, fmt.Errorf("Unable to filter containers: %w", err)
}
if result.ExitCode != 0 {
return []string{}, fmt.Errorf("Unable to filter containers: %s", result.StderrContents())
}
output := strings.Split(result.StdoutContents(), "\n")
return output, nil
}
// DockerFilterImages returns a slice of image IDs based on the passed in filters
func DockerFilterImages(filters []string) ([]string, error) {
args := []string{
"image",
"ls",
"--quiet",
"--all",
}
for _, filter := range filters {
args = append(args, "--filter", filter)
}
result, err := CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: args,
})
if err != nil {
return []string{}, fmt.Errorf("Unable to filter images: %w", err)
}
if result.ExitCode != 0 {
return []string{}, fmt.Errorf("Unable to filter images: %s", result.StderrContents())
}
output := strings.Split(result.StdoutContents(), "\n")
return output, nil
}
func listContainers(status string, appName string) ([]string, error) {
filters := []string{
fmt.Sprintf("status=%v", status),
fmt.Sprintf("label=%v", os.Getenv("DOKKU_CONTAINER_LABEL")),
}
if appName != "" {
filters = append(filters, fmt.Sprintf("label=com.dokku.app-name=%v", appName))
}
return DockerFilterContainers(filters)
}
func pruneUnusedImages(appName string) {
args := []string{
"image",
"prune",
"--all",
"--force",
"--filter",
fmt.Sprintf("label=com.dokku.app-name=%v", appName),
}
CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: args,
}) // nolint: errcheck
}
// DockerRemoveContainers will call `docker container rm` on the specified containers
func DockerRemoveContainers(containerIDs []string) {
args := []string{
"container",
"rm",
}
args = append(args, containerIDs...)
CallExecCommand(ExecCommandInput{
Command: DockerBin(),
Args: args,
}) // nolint: errcheck
}