From f98e7702fbf686f257c569059a2ba5bf127cf2aa Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Sun, 21 Feb 2021 21:00:14 -0500 Subject: [PATCH] feat: implement heroku's postdeploy deployment task This occurs during the postdeploy on the first deploy of an app, mimicking heroku. It currently happens _after_ the `postdeploy` and `release` deploy tasks. --- docs/advanced-usage/deployment-tasks.md | 10 +++++++- plugins/app-json/appjson.go | 1 + plugins/app-json/functions.go | 24 ++++++++++++------- plugins/app-json/triggers.go | 32 +++++++++++++++++-------- tests/unit/app-json.bats | 12 +++++++++- tests/unit/test_helper.bash | 11 +++++++++ 6 files changed, 70 insertions(+), 20 deletions(-) diff --git a/docs/advanced-usage/deployment-tasks.md b/docs/advanced-usage/deployment-tasks.md index a47465d4d..5c62a8ae0 100644 --- a/docs/advanced-usage/deployment-tasks.md +++ b/docs/advanced-usage/deployment-tasks.md @@ -24,6 +24,12 @@ Each "phase" has different expectations and limitations: - Example use-cases - Notifying slack that your app is deployed - Coordinating traffic routing with a central load balancer +- `app.json`: `scripts.postdeploy` + - When to use: This should be used when you wish to run a command _once_, after the app is created and not on subsequent deploys to the app. + - Are changes committed to the image at this phase: No + - Example use-cases + - Setting up OAuth clients and DNS + - Loading seed/test data into the app’s test database - `Procfile`: `release` - When to use: This should be used in conjunction with external systems to signal the completion of your app image build. - Are changes committed to the image at this phase: No @@ -44,6 +50,7 @@ Dokku provides limited support for the `app.json` manifest from Heroku (document - `scripts.dokku.predeploy`: This is run _after_ an app's docker image is built, but _before_ any containers are scheduled. Changes made to your image are committed at this phase. - `scripts.dokku.postdeploy`: This is run _after_ an app's containers are scheduled. Changes made to your image are *not* committed at this phase. +- `scripts.postdeploy`: This is run _after_ an app's containers are scheduled. Changes made to your image are *not* committed at this phase. For buildpack-based deployments, the location of the `app.json` file should be at the root of your repository. Dockerfile-based app deploys should have the `app.json` in the configured `WORKDIR` directory; otherwise Dokku defaults to the buildpack app behavior of looking in `/app`. @@ -57,7 +64,8 @@ The following is an example `app.json` file. Please note that only the `scripts. "dokku": { "predeploy": "touch /app/predeploy.test", "postdeploy": "curl https://some.external.api.service.com/deployment?state=success" - } + }, + "postdeploy": "curl https://some.external.api.service.com/created?state=success" } } ``` diff --git a/plugins/app-json/appjson.go b/plugins/app-json/appjson.go index 5290c40dc..ad4871fbc 100644 --- a/plugins/app-json/appjson.go +++ b/plugins/app-json/appjson.go @@ -14,6 +14,7 @@ type AppJSON struct { Predeploy string `json:"predeploy"` Postdeploy string `json:"postdeploy"` } `json:"dokku"` + Postdeploy string `json:"postdeploy"` } `json:"scripts"` } diff --git a/plugins/app-json/functions.go b/plugins/app-json/functions.go index c86b78932..a41071fdf 100644 --- a/plugins/app-json/functions.go +++ b/plugins/app-json/functions.go @@ -91,6 +91,10 @@ func getPhaseScript(appName string, phase string) (string, error) { return "", fmt.Errorf("Cannot parse app.json: %v", err) } + if phase == "heroku.postdeploy" { + return appJSON.Scripts.Postdeploy, nil + } + if phase == "predeploy" { return appJSON.Scripts.Dokku.Predeploy, nil } @@ -143,7 +147,11 @@ func getDokkuAppShell(appName string) string { } func executeScript(appName string, image string, imageTag string, phase string) error { - common.LogInfo1(fmt.Sprintf("Checking for %s task", phase)) + phaseName := phase + if phase == "heroku.postdeploy" { + phaseName = "first deploy postdeploy" + } + common.LogInfo1(fmt.Sprintf("Checking for %s task", phaseName)) command := "" phaseSource := "" if phase == "release" { @@ -158,11 +166,11 @@ func executeScript(appName string, image string, imageTag string, phase string) } if command == "" { - common.LogVerbose(fmt.Sprintf("No %s task found, skipping", phase)) + common.LogVerbose(fmt.Sprintf("No %s task found, skipping", phaseName)) return nil } - common.LogInfo1(fmt.Sprintf("Executing %s task from %s: %s", phase, phaseSource, command)) + common.LogInfo1(fmt.Sprintf("Executing %s task from %s: %s", phaseName, phaseSource, command)) isHerokuishImage := common.IsImageHerokuishBased(image, appName) isCnbImage := common.IsImageCnbBased(image) dockerfileEntrypoint := "" @@ -241,15 +249,15 @@ func executeScript(appName string, image string, imageTag string, phase string) } if !waitForExecution(containerID) { - common.LogInfo2Quiet(fmt.Sprintf("Start of %s %s task (%s) output", appName, phase, containerID[0:9])) + common.LogInfo2Quiet(fmt.Sprintf("Start of %s %s task (%s) output", appName, phaseName, 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("End of %s %s task (%s) output", appName, phaseName, containerID[0:9])) + common.LogFail(fmt.Sprintf("Execution of %s task failed: %s", phase, phaseName)) } - common.LogInfo2Quiet(fmt.Sprintf("Start of %s %s task (%s) output", appName, phase, containerID[0:9])) + common.LogInfo2Quiet(fmt.Sprintf("Start of %s %s task (%s) output", appName, phaseName, containerID[0:9])) common.LogVerboseQuietContainerLogs(containerID) - common.LogInfo2Quiet(fmt.Sprintf("End of %s %s task (%s) output", appName, phase, containerID[0:9])) + common.LogInfo2Quiet(fmt.Sprintf("End of %s %s task (%s) output", appName, phaseName, containerID[0:9])) if phase != "predeploy" { return nil diff --git a/plugins/app-json/triggers.go b/plugins/app-json/triggers.go index e83f065ad..c3ccc05b5 100644 --- a/plugins/app-json/triggers.go +++ b/plugins/app-json/triggers.go @@ -1,6 +1,7 @@ package appjson import ( + "fmt" "os" "path/filepath" @@ -9,16 +10,16 @@ import ( // TriggerInstall initializes app restart policies func TriggerInstall() error { + if err := common.PropertySetup("common"); err != nil { + return fmt.Errorf("Unable to install the common plugin: %s", err.Error()) + } + directory := filepath.Join(common.MustGetEnv("DOKKU_LIB_ROOT"), "data", "app-json") if err := os.MkdirAll(directory, 0755); err != nil { return err } - if err := common.SetPermissions(directory, 0755); err != nil { - return err - } - - return nil + return common.SetPermissions(directory, 0755) } // TriggerPostDelete destroys the app-json data for a given app container @@ -41,10 +42,7 @@ func TriggerPostDeploy(appName string, imageTag string) error { return err } - if err := executeScript(appName, image, imageTag, "postdeploy"); err != nil { - return err - } - return nil + return executeScript(appName, image, imageTag, "postdeploy") } // TriggerPreDeploy is a trigger to execute predeploy and release deployment tasks @@ -65,5 +63,19 @@ func TriggerPreDeploy(appName string, imageTag string) error { if err := executeScript(appName, image, imageTag, "release"); err != nil { return err } - return nil + + if common.PropertyGet("common", appName, "deployed") == "true" { + return nil + } + + // Ensure that a failed postdeploy does not trigger twice + if common.PropertyGet("app-json", appName, "heroku.postdeploy") == "executed" { + return nil + } + + if err := common.PropertyWrite("app-json", appName, "heroku.postdeploy", "executed"); err != nil { + return err + } + + return executeScript(appName, image, imageTag, "heroku.postdeploy") } diff --git a/tests/unit/app-json.bats b/tests/unit/app-json.bats index 4f87bc727..da66432a4 100644 --- a/tests/unit/app-json.bats +++ b/tests/unit/app-json.bats @@ -18,7 +18,7 @@ teardown() { } @test "(app-json) app.json scripts" { - run deploy_app nodejs-express + run deploy_app python echo "output: $output" echo "status: $status" assert_success @@ -48,6 +48,16 @@ teardown() { assert_success } + +@test "(app-json) app.json scripts postdeploy" { + run deploy_app python dokku@dokku.me:$TEST_APP add_postdeploy_command + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "touch /app/release.test" 2 + assert_output_contains "python3 release.py" 2 +} + @test "(app-json) app.json scripts missing" { run deploy_app nodejs-express-noappjson echo "output: $output" diff --git a/tests/unit/test_helper.bash b/tests/unit/test_helper.bash index 93c21fc5d..7d68ec34e 100644 --- a/tests/unit/test_helper.bash +++ b/tests/unit/test_helper.bash @@ -514,6 +514,17 @@ add_release_command() { echo "release: touch /app/release.test" >>"$APP_REPO_DIR/Procfile" } + +add_postdeploy_command() { + local APP="$1" + local APP_REPO_DIR="$2" + [[ -z "$APP" ]] && local APP="$TEST_APP" + touch "$APP_REPO_DIR/app.json" + contents=$(jq '.scripts.postdeploy = "touch /app/heroku-postdeploy.test"' "$APP_REPO_DIR/app.json") + echo "${contents}" > "$APP_REPO_DIR/app.json" +} + + add_requirements_txt() { local APP="$1" local APP_REPO_DIR="$2"