Files
dokku/plugins/app-json/appjson.go
Jose Diaz-Gonzalez ef490f0a05 feat: retire intermediate containers after use
Rather than waiting for the next deploy, take advantage of the retire system to retire these containers immediately.

Note that since the retire process happens out of band, the containers may stick around for up to 5 minutes, after which point they will be removed.

Customization of the wait time to retire - currently 60 seconds - is up for future debate. The containers ultimately haven't been useful for debugging, so keeping them around for longer won't help in most cases, and folks can disable the dokku-retire service (or cron) if that is desired.
2020-11-22 16:57:39 -05:00

380 lines
11 KiB
Go

package appjson
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"os"
"strings"
"github.com/dokku/dokku/plugins/common"
shellquote "github.com/kballard/go-shellquote"
)
// AppJSON is a struct that represents an app.json file as understood by Dokku
type AppJSON struct {
Scripts struct {
Dokku struct {
Predeploy string `json:"predeploy"`
Postdeploy string `json:"postdeploy"`
} `json:"dokku"`
} `json:"scripts"`
}
func constructScript(command string, shell string, isHerokuishImage bool, hasEntrypoint bool) []string {
if hasEntrypoint {
return []string{command}
}
script := []string{"set -eo pipefail;"}
if os.Getenv("DOKKU_TRACE") == "1" {
script = append(script, "set -x;")
}
if isHerokuishImage {
script = append(script, []string{
"if [[ -d '/app' ]]; then",
" export HOME=/app;",
" cd $HOME;",
"fi;",
"if [[ -d '/app/.profile.d' ]]; then",
" for file in /app/.profile.d/*; do source $file; done;",
"fi;",
"if [[ -d '/cache' ]]; then",
" rm -rf /tmp/cache ;",
" ln -sf /cache /tmp/cache;",
"fi;",
}...)
}
if strings.HasPrefix(command, "/") {
commandBin := strings.Split(command, " ")[0]
script = append(script, []string{
fmt.Sprintf("if [[ ! -x \"%s\" ]]; then", commandBin),
" echo specified binary is not executable;",
" exit 1;",
"fi;",
}...)
}
script = append(script, fmt.Sprintf("%s || exit 1;", command))
if isHerokuishImage {
script = append(script, []string{
"if [[ -d '/cache' ]]; then",
" rm -f /tmp/cache;",
"fi;",
}...)
}
return []string{shell, "-c", strings.Join(script, " ")}
}
// getPhaseScript extracts app.json from app image and returns the appropriate json key/value
func getPhaseScript(appName string, image string, phase string) (string, error) {
appJSONFile, err := ioutil.TempFile(os.TempDir(), fmt.Sprintf("dokku-%s-%s", common.MustGetEnv("DOKKU_PID"), "getPhaseScript"))
if err != nil {
return "", fmt.Errorf("Cannot create temporary file: %v", err)
}
defer os.Remove(appJSONFile.Name())
common.CopyFromImage(appName, image, "app.json", appJSONFile.Name())
if !common.FileExists(appJSONFile.Name()) {
return "", nil
}
b, err := ioutil.ReadFile(appJSONFile.Name())
if err != nil {
return "", fmt.Errorf("Cannot read app.json file: %v", err)
}
if strings.TrimSpace(string(b)) == "" {
return "", nil
}
var appJSON AppJSON
if err = json.Unmarshal(b, &appJSON); err != nil {
return "", fmt.Errorf("Cannot parse app.json: %v", err)
}
if phase == "predeploy" {
return appJSON.Scripts.Dokku.Predeploy, nil
}
return appJSON.Scripts.Dokku.Postdeploy, nil
}
// getReleaseCommand extracts the release command from a given app's procfile
func getReleaseCommand(appName string, image string) string {
err := common.SuppressOutput(func() error {
return common.PlugnTrigger("procfile-extract", []string{appName, image}...)
})
if err != nil {
return ""
}
processType := "release"
port := "5000"
b, _ := common.PlugnTriggerOutput("procfile-get-command", []string{appName, processType, port}...)
return strings.TrimSpace(string(b[:]))
}
func getDokkuAppShell(appName string) string {
dokkuAppShell := "/bin/bash"
if b, _ := common.PlugnTriggerOutput("config-get-global", []string{"DOKKU_APP_SHELL"}...); strings.TrimSpace(string(b[:])) != "" {
dokkuAppShell = strings.TrimSpace(string(b[:]))
}
if b, _ := common.PlugnTriggerOutput("config-get", []string{appName, "DOKKU_APP_SHELL"}...); strings.TrimSpace(string(b[:])) != "" {
dokkuAppShell = strings.TrimSpace(string(b[:]))
}
return dokkuAppShell
}
func executeScript(appName string, imageTag string, phase string) error {
image := common.GetDeployingAppImageName(appName, imageTag, "")
command := ""
phaseSource := ""
if phase == "release" {
command = getReleaseCommand(appName, image)
phaseSource = "Procfile"
} else {
var err error
phaseSource = "app.json"
if command, err = getPhaseScript(appName, image, phase); err != nil {
common.LogExclaim(err.Error())
}
}
if command == "" {
return nil
}
common.LogInfo1(fmt.Sprintf("Executing %s task from %s: %s", phase, phaseSource, command))
isHerokuishImage := common.IsImageHerokuishBased(image, appName)
dockerfileEntrypoint := ""
dockerfileCommand := ""
if !isHerokuishImage {
dockerfileEntrypoint, _ = getEntrypointFromImage(image)
dockerfileCommand, _ = getCommandFromImage(image)
}
hasEntrypoint := dockerfileEntrypoint != ""
dokkuAppShell := getDokkuAppShell(appName)
script := constructScript(command, dokkuAppShell, isHerokuishImage, hasEntrypoint)
imageSourceType := "dockerfile"
if isHerokuishImage {
imageSourceType = "herokuish"
}
cacheDir := fmt.Sprintf("%s/cache", common.AppRoot(appName))
cacheHostDir := fmt.Sprintf("%s/cache", common.AppHostRoot(appName))
if !common.DirectoryExists(cacheDir) {
os.MkdirAll(cacheDir, 0755)
}
var dockerArgs []string
if b, err := common.PlugnTriggerSetup("docker-args-deploy", []string{appName, imageTag}...).SetInput("").Output(); err == nil {
words, err := shellquote.Split(strings.TrimSpace(string(b[:])))
if err != nil {
return err
}
dockerArgs = append(dockerArgs, words...)
}
if b, err := common.PlugnTriggerSetup("docker-args-process-deploy", []string{appName, imageSourceType, imageTag}...).SetInput("").Output(); err == nil {
words, err := shellquote.Split(strings.TrimSpace(string(b[:])))
if err != nil {
return err
}
dockerArgs = append(dockerArgs, words...)
}
filteredArgs := []string{"restart", "cpus", "memory", "memory-swap", "memory-reservation", "gpus"}
for _, filteredArg := range filteredArgs {
// re := regexp.MustCompile("--" + filteredArg + "=[0-9A-Za-z!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~]+ ")
var filteredDockerArgs []string
for _, dockerArg := range dockerArgs {
if !strings.HasPrefix(dockerArg, "--"+filteredArg+"=") {
filteredDockerArgs = append(filteredDockerArgs, dockerArg)
}
}
dockerArgs = filteredDockerArgs
}
dockerArgs = append(dockerArgs, "--label=dokku_phase_script="+phase)
dockerArgs = append(dockerArgs, "-v", cacheHostDir+":/cache")
if os.Getenv("DOKKU_TRACE") != "" {
dockerArgs = append(dockerArgs, "--env", "DOKKU_TRACE="+os.Getenv("DOKKU_TRACE"))
}
containerID, err := createdContainerID(appName, dockerArgs, image, script, phase)
if err != nil {
common.LogFail(fmt.Sprintf("Failed to create %s execution container: %s", phase, err.Error()))
}
if !waitForExecution(containerID) {
common.LogInfo2Quiet(fmt.Sprintf("Start of %s %s task (%s) output", appName, phase, containerID[0:9]))
common.LogVerboseQuietContainerLogs(containerID)
common.LogInfo2Quiet(fmt.Sprintf("End of %s %s task (%s) output", appName, phase, containerID[0:9]))
common.LogFail(fmt.Sprintf("Execution of %s task failed: %s", phase, command))
}
common.LogInfo2Quiet(fmt.Sprintf("Start of %s %s task (%s) output", appName, phase, containerID[0:9]))
common.LogVerboseQuietContainerLogs(containerID)
common.LogInfo2Quiet(fmt.Sprintf("End of %s %s task (%s) output", appName, phase, containerID[0:9]))
if phase != "predeploy" {
return nil
}
commitArgs := []string{"container", "commit"}
if !isHerokuishImage {
if dockerfileEntrypoint != "" {
commitArgs = append(commitArgs, "--change", dockerfileEntrypoint)
}
if dockerfileCommand != "" {
commitArgs = append(commitArgs, "--change", dockerfileCommand)
}
}
commitArgs = append(commitArgs, []string{
"--change",
"LABEL org.label-schema.schema-version=1.0",
"--change",
"LABEL org.label-schema.vendor=dokku",
"--change",
fmt.Sprintf("LABEL com.dokku.app-name=%s", appName),
"--change",
fmt.Sprintf("LABEL com.dokku.%s-phase=true", phase),
}...)
commitArgs = append(commitArgs, containerID, image)
containerCommitCmd := common.NewShellCmdWithArgs(
common.DockerBin(),
commitArgs...,
)
containerCommitCmd.ShowOutput = false
containerCommitCmd.Command.Stderr = os.Stderr
if !containerCommitCmd.Execute() {
common.LogFail(fmt.Sprintf("Commiting of '%s' to image failed: %s", phase, command))
}
return common.PlugnTrigger("scheduler-register-retired", []string{appName, containerID}...)
}
func getEntrypointFromImage(image string) (string, error) {
output, err := common.DockerInspect(image, "{{json .Config.Entrypoint}}")
if err != nil {
return "", err
}
if output == "null" {
return "", err
}
var entrypoint []string
if err = json.Unmarshal([]byte(output), &entrypoint); err != nil {
return "", err
}
if len(entrypoint) == 3 && entrypoint[0] == "/bin/sh" && entrypoint[1] == "-c" {
return fmt.Sprintf("ENTRYPOINT %s", entrypoint[2]), nil
}
serializedEntrypoint, err := json.Marshal(entrypoint)
return fmt.Sprintf("ENTRYPOINT %s", string(serializedEntrypoint)), err
}
func getCommandFromImage(image string) (string, error) {
output, err := common.DockerInspect(image, "{{json .Config.Cmd}}")
if err != nil {
return "", err
}
if output == "null" {
return "", err
}
var command []string
if err = json.Unmarshal([]byte(output), &command); err != nil {
return "", err
}
if len(command) == 3 && command[0] == "/bin/sh" && command[1] == "-c" {
return fmt.Sprintf("CMD %s", command[2]), nil
}
serializedEntrypoint, err := json.Marshal(command)
return fmt.Sprintf("CMD %s", string(serializedEntrypoint)), err
}
func waitForExecution(containerID string) bool {
containerStartCmd := common.NewShellCmdWithArgs(
common.DockerBin(),
"container",
"start",
containerID,
)
containerStartCmd.ShowOutput = false
containerStartCmd.Command.Stderr = os.Stderr
if !containerStartCmd.Execute() {
return false
}
containerWaitCmd := common.NewShellCmdWithArgs(
common.DockerBin(),
"container",
"wait",
containerID,
)
containerWaitCmd.ShowOutput = false
containerWaitCmd.Command.Stderr = os.Stderr
b, err := containerWaitCmd.Output()
if err != nil {
return false
}
containerExitCode := strings.TrimSpace(string(b[:]))
return containerExitCode == "0"
}
func createdContainerID(appName string, dockerArgs []string, image string, command []string, phase string) (string, error) {
runLabelArgs := fmt.Sprintf("--label=com.dokku.app-name=%s", appName)
arguments := strings.Split(common.MustGetEnv("DOKKU_GLOBAL_RUN_ARGS"), " ")
arguments = append(arguments, runLabelArgs)
arguments = append(arguments, dockerArgs...)
arguments = append([]string{"container", "create"}, arguments...)
arguments = append(arguments, image)
arguments = append(arguments, command...)
containerCreateCmd := common.NewShellCmdWithArgs(
common.DockerBin(),
arguments...,
)
var stderr bytes.Buffer
containerCreateCmd.ShowOutput = false
containerCreateCmd.Command.Stderr = &stderr
b, err := containerCreateCmd.Output()
if err != nil {
return "", errors.New(stderr.String())
}
containerID := strings.TrimSpace(string(b))
err = common.PlugnTrigger("post-container-create", []string{"app", appName, containerID, phase}...)
return containerID, err
}