feat: add post-create support for env key in app.json

Closes #4498
This commit is contained in:
Jose Diaz-Gonzalez
2026-01-07 03:21:04 -05:00
parent ad00918891
commit 34c336c668
14 changed files with 653 additions and 4 deletions

View File

@@ -24,6 +24,45 @@ var (
}
)
// EnvVarValue represents an environment variable definition in app.json
// It supports both simple string values and complex object definitions
type EnvVarValue struct {
// Description provides context for the env var (used in prompts)
Description string `json:"description,omitempty"`
// Value is the default value for the env var
Value string `json:"value,omitempty"`
// Required indicates if the env var must have a value (defaults to true per Heroku spec)
Required *bool `json:"required,omitempty"`
// Generator specifies how to auto-generate a value (currently only "secret" is supported)
Generator string `json:"generator,omitempty"`
// Sync indicates if the value should be set on every deploy, not just first
Sync bool `json:"sync,omitempty"`
}
// UnmarshalJSON handles both string and object formats for env vars
func (e *EnvVarValue) UnmarshalJSON(data []byte) error {
var str string
if err := json.Unmarshal(data, &str); err == nil {
e.Value = str
return nil
}
type envVarAlias EnvVarValue
return json.Unmarshal(data, (*envVarAlias)(e))
}
// IsRequired returns true if the env var is required (defaults to true per Heroku spec)
func (e *EnvVarValue) IsRequired() bool {
if e.Required == nil {
return true
}
return *e.Required
}
// AppJSON is a struct that represents an app.json file as understood by Dokku
type AppJSON struct {
// Buildpacks is a list of buildpacks to use for the app
@@ -32,6 +71,9 @@ type AppJSON struct {
// Cron is a list of cron tasks to execute
Cron []CronTask `json:"cron"`
// Env is a map of environment variables to set
Env map[string]EnvVarValue `json:"env,omitempty"`
// Formation is a map of process types to scale
Formation map[string]Formation `json:"formation"`

View File

@@ -1,16 +1,21 @@
package appjson
import (
"bufio"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"github.com/dokku/dokku/plugins/common"
shellquote "github.com/kballard/go-shellquote"
"github.com/mattn/go-isatty"
)
func constructScript(command string, shell string, isHerokuishImage bool, isCnbImage bool, dockerfileEntrypoint string) []string {
@@ -455,3 +460,199 @@ func setScale(appName string) error {
})
return err
}
// generateSecret generates a cryptographically secure random hex string of specified length
func generateSecret(length int) (string, error) {
bytes := make([]byte, length/2)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate secret: %w", err)
}
return hex.EncodeToString(bytes), nil
}
// isInteractive returns true if stdin is a terminal (TTY available)
func isInteractive() bool {
return isatty.IsTerminal(os.Stdin.Fd()) || isatty.IsCygwinTerminal(os.Stdin.Fd())
}
// promptForValue prompts the user for an environment variable value
func promptForValue(varName string, envVar EnvVarValue) (string, error) {
prompt := fmt.Sprintf("Enter value for %s", varName)
if envVar.Description != "" {
prompt = fmt.Sprintf("Enter value for %s (%s)", varName, envVar.Description)
}
common.LogInfo1(prompt)
fmt.Print("> ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(response), nil
}
// getCurrentConfig retrieves the current config values for an app
func getCurrentConfig(appName string) (map[string]string, error) {
results, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "config-export",
Args: []string{appName, "false", "true", "json"},
})
if err != nil {
return nil, fmt.Errorf("failed to get current config: %w", err)
}
var currentConfig map[string]string
if err := json.Unmarshal(results.StdoutBytes(), &currentConfig); err != nil {
return nil, fmt.Errorf("failed to parse current config: %w", err)
}
return currentConfig, nil
}
// setConfigVars sets multiple config vars for an app without triggering restart
func setConfigVars(appName string, vars map[string]string) error {
if len(vars) == 0 {
return nil
}
// Build arguments for config-set trigger: --no-restart flag, appName, key=value pairs
args := []string{"--no-restart", appName}
for key, value := range vars {
args = append(args, fmt.Sprintf("%s=%s", key, value))
}
_, err := common.CallPlugnTrigger(common.PlugnTriggerInput{
Trigger: "config-set",
Args: args,
StreamStdio: true,
})
if err != nil {
return fmt.Errorf("failed to set config vars: %w", err)
}
return nil
}
// processAppJSONEnv processes env vars from app.json and sets them as config
func processAppJSONEnv(appName string) error {
appJSON, err := GetAppJSON(appName)
if err != nil {
return err
}
if len(appJSON.Env) == 0 {
return nil
}
common.LogInfo1("Processing app.json env vars")
isFirstDeploy := common.PropertyGet("common", appName, "deployed") != "true"
// Check if env vars have been processed for this deploy
envProcessedProperty := "appjson-env-processed"
alreadyProcessed := common.PropertyGet("app-json", appName, envProcessedProperty) == "executed"
// Get current config values
currentConfig, err := getCurrentConfig(appName)
if err != nil {
return err
}
varsToSet := make(map[string]string)
interactive := isInteractive()
// Sort keys for deterministic ordering
var keys []string
for k := range appJSON.Env {
keys = append(keys, k)
}
sort.Strings(keys)
for _, varName := range keys {
envVar := appJSON.Env[varName]
// Determine if we should process this var
shouldProcess := false
if isFirstDeploy && !alreadyProcessed {
// First deploy: process all vars
shouldProcess = true
} else if envVar.Sync {
// Subsequent deploy with sync: always process sync vars
shouldProcess = true
}
if !shouldProcess {
continue
}
// Check if already set (for first deploy, skip if already has value and not sync)
currentValue, hasValue := currentConfig[varName]
if hasValue && currentValue != "" && !envVar.Sync {
common.LogDebug(fmt.Sprintf("Skipping %s: already set", varName))
continue
}
// Determine the value
var value string
// Handle generator
if envVar.Generator == "secret" {
if !hasValue || currentValue == "" || envVar.Sync {
generated, err := generateSecret(64)
if err != nil {
return fmt.Errorf("failed to generate secret for %s: %w", varName, err)
}
value = generated
common.LogDebug(fmt.Sprintf("Generated secret for %s", varName))
} else {
continue // Already has value, not sync
}
} else if envVar.Value != "" {
// Use default value
value = envVar.Value
} else if interactive {
// Prompt user
prompted, err := promptForValue(varName, envVar)
if err != nil {
if envVar.IsRequired() {
return fmt.Errorf("required env var %s has no value and prompt failed: %w", varName, err)
}
continue
}
if prompted == "" && envVar.IsRequired() {
return fmt.Errorf("required env var %s cannot be empty", varName)
}
value = prompted
} else {
// No TTY, no default, no generator
if envVar.IsRequired() {
return fmt.Errorf("required env var %s has no value, no default, and no TTY for prompt", varName)
}
continue
}
if value != "" {
varsToSet[varName] = value
}
}
if len(varsToSet) == 0 {
common.LogVerbose("No env vars to set from app.json")
if !isFirstDeploy {
return common.PropertyWrite("app-json", appName, envProcessedProperty, "executed")
}
return nil
}
// Set the config vars without restart (we're in middle of deploy)
common.LogInfo1(fmt.Sprintf("Setting %d env var(s) from app.json", len(varsToSet)))
if err := setConfigVars(appName, varsToSet); err != nil {
return fmt.Errorf("failed to set env vars: %w", err)
}
// Mark as processed
return common.PropertyWrite("app-json", appName, envProcessedProperty, "executed")
}

View File

@@ -71,6 +71,11 @@ func TriggerCorePostDeploy(appName string) error {
// TriggerCorePostExtract ensures that the main app.json is the one specified by app-json-path
func TriggerCorePostExtract(appName string, sourceWorkDir string) error {
// Clear the env-processed property on new source extraction to allow re-processing
if err := common.PropertyDelete("app-json", appName, "appjson-env-processed"); err != nil {
return err
}
destination := common.GetAppDataDirectory("app-json", appName)
appJSONPath := strings.Trim(reportComputedAppjsonpath(appName), "/")
if appJSONPath == "" {
@@ -190,7 +195,13 @@ func TriggerPostDeploy(appName string, imageTag string) error {
return executeScript(appName, image, imageTag, "postdeploy")
}
// TriggerPreReleaseBuilder processes app.json env vars and executes the predeploy task
func TriggerPreReleaseBuilder(builderType string, appName string, image string) error {
// Process app.json env vars before predeploy script
if err := processAppJSONEnv(appName); err != nil {
return err
}
parts := strings.Split(image, ":")
imageTag := parts[len(parts)-1]
return executeScript(appName, image, imageTag, "predeploy")

View File

@@ -78,7 +78,7 @@ trigger-builder-herokuish-builder-build() {
fn-builder-herokuish-ensure-cache "$APP"
if ! CID=$("$DOCKER_BIN" container create "${DOCKER_RUN_LABEL_ARGS[@]}" $DOKKU_GLOBAL_RUN_ARGS -v "cache-$APP:/cache" --env=CACHE_PATH=/cache "${ARG_ARRAY[@]}" "$IMAGE" /build); then
plugn trigger scheduler-register-retired "$APP" "$CID"
dokku_log_warn "Failure during app build"
dokku_log_warn "Failure creating container during app build"
return 1
fi
@@ -86,14 +86,14 @@ trigger-builder-herokuish-builder-build() {
"$DOCKER_BIN" container start "$CID" >/dev/null || DOKKU_CONTAINER_EXIT_CODE=$?
if ! "$DOCKER_BIN" container attach "$CID"; then
plugn trigger scheduler-register-retired "$APP" "$CID"
dokku_log_warn "Failure during app build"
dokku_log_warn "Failure starting container during app build"
return 1
fi
DOKKU_CONTAINER_EXIT_CODE="$("$DOCKER_BIN" container wait "$CID" 2>/dev/null || echo "$DOKKU_CONTAINER_EXIT_CODE")"
if [[ "$DOKKU_CONTAINER_EXIT_CODE" -ne 0 ]]; then
plugn trigger scheduler-register-retired "$APP" "$CID"
dokku_log_warn "Failure during app build"
dokku_log_warn "Failure attaching to container during app build"
return 1
fi

View File

@@ -1,6 +1,6 @@
GOARCH ?= amd64
SUBCOMMANDS = subcommands/bundle subcommands/clear subcommands/export subcommands/get subcommands/import subcommands/keys subcommands/show subcommands/set subcommands/unset
TRIGGERS = triggers/config-export triggers/config-get triggers/config-get-global triggers/config-unset triggers/post-app-clone-setup triggers/post-app-rename-setup
TRIGGERS = triggers/config-export triggers/config-get triggers/config-get-global triggers/config-set triggers/config-unset triggers/post-app-clone-setup triggers/post-app-rename-setup
BUILD = commands config_sub subcommands triggers
PLUGIN_NAME = config

View File

@@ -65,3 +65,14 @@ config_bundle() {
declare desc="export tar bundle of config"
config_sub bundle "$@"
}
fn-config-set() {
declare desc="set config values via trigger (supports --no-restart)"
declare APP="$1" NO_RESTART="$2"
shift 2
if [[ "$NO_RESTART" == "true" ]]; then
plugn trigger config-set --no-restart "$APP" "$@"
else
plugn trigger config-set "$APP" "$@"
fi
}

View File

@@ -15,6 +15,7 @@ func main() {
parts := strings.Split(os.Args[0], "/")
trigger := parts[len(parts)-1]
global := flag.Bool("global", false, "--global: Whether global or app-specific")
noRestart := flag.Bool("no-restart", false, "--no-restart: Whether to skip restart")
flag.Parse()
var err error
@@ -36,6 +37,10 @@ func main() {
case "config-get-global":
key := flag.Arg(0)
err = config.TriggerConfigGetGlobal(key)
case "config-set":
appName := flag.Arg(0)
pairs := flag.Args()[1:]
err = config.TriggerConfigSet(appName, *noRestart, pairs...)
case "config-unset":
appName := flag.Arg(0)
key := flag.Arg(1)

View File

@@ -45,6 +45,11 @@ func TriggerConfigGetGlobal(key string) error {
return nil
}
// TriggerConfigSet sets config values for an app
func TriggerConfigSet(appName string, noRestart bool, pairs ...string) error {
return SubSet(appName, pairs, noRestart, false)
}
// TriggerConfigUnset unsets an app config value by key
func TriggerConfigUnset(appName string, key string, restart bool) error {
UnsetMany(appName, []string{key}, restart)