diff --git a/README.md b/README.md index d25714a..5b88a52 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,92 @@ GitHub Action and Docker image used to deploy a Docker stack on a Docker Swarm. | `prune` | `PRUNE` | Prune services that are not defined in the stack file | | **0** | | `stack_file` | `STACK_FILE` | Path to the stack file used in the deploy. | ✅ | | | `stack_name` | `STACK_NAME` | Name of the stack to be deployed. | ✅ | | -| `stack_param` | `STACK_PARAM` | Additional parameter (env var) to be passed to the stack. | | | -| `env_file` | `ENV_FILE` | Additional environment variables to be passed to the stack. | | | +| `stack_param` | `STACK_PARAM` | A single additional value, available to the stack file as `${STACK_PARAM}`. Superseded by `env_file`, see [Passing values into the stack file](#passing-values-into-the-stack-file). | | | +| `env_file` | `ENV_FILE` | Additional environment variables **as content**, one `VAR=VALUE` per line. Mutually exclusive with `env_file_path`. | | | +| `env_file_path` | `ENV_FILE_PATH` | **Path** to a file of additional environment variables, one `VAR=VALUE` per line. Mutually exclusive with `env_file`. | | | | `debug` | `DEBUG` | Verbose logging | | **0** | | `scale_after` | `SCALE_AFTER` | Scale a service after a deployment has converged successfully. Example: servicename=1 | | | +## Passing values into the stack file + +Your stack file can reference environment variables, and `docker stack deploy` +substitutes them as it reads the file. This action gives you three ways to set +those variables, all of which work through that same substitution. + +### `env_file` — the variables themselves + +Pass the content directly. Each line is a `VAR=VALUE` pair: + +```yaml + - name: Deploy + uses: kitconcept/docker-stack-deploy@v1.4.0 + with: + # ... + env_file: | + BACKEND_REPLICAS=2 + FRONTEND_REPLICAS=3 + SOLR_JAVA_MEM=-Xms1536m -Xmx1536m +``` + +```yaml +# stacks/plone.yml +services: + backend: + deploy: + replicas: ${BACKEND_REPLICAS} + frontend: + deploy: + replicas: ${FRONTEND_REPLICAS} +``` + +Values are taken **verbatim**, matching `docker --env-file`: a value may +contain spaces, and quotes around it become part of the value rather than +being stripped. So `GREETING="hello"` sets `GREETING` to `"hello"`, quotes +included. + +Lines that are blank or start with `#` are ignored. Anything else that is not +`NAME=VALUE` is an error naming the offending line, rather than a silent +failure to export. + +### `env_file_path` — a file to read them from + +If the variables already live in a file in your repository, point at it +instead. Relative paths resolve against the workspace, the same as `stack_file`: + +```yaml + with: + # ... + env_file_path: "stacks/production.env" +``` + +The file format and the verbatim semantics are identical to `env_file`. The two +inputs are mutually exclusive — supplying both is an error, because the +precedence between them would otherwise be arbitrary. + +### `stack_param` — a single value (discouraged) + +`stack_param` sets one variable, always named `STACK_PARAM`: + +```yaml + with: + # ... + stack_param: "2" +``` + +```yaml +# stacks/plone.yml +services: + backend: + deploy: + replicas: ${STACK_PARAM} +``` + +It works, and it is kept for compatibility, but it is a one-variable special +case of `env_file` with a name you cannot choose. Prefer `env_file` or +`env_file_path` for anything new. + + ## Using the GitHub Action Add, or edit an existing, `yaml` file inside `.github/actions` and use the configuration options listed above. @@ -66,10 +146,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout codebase - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: Deploy - uses: kitconcept/docker-stack-deploy@v1.0.1 + uses: kitconcept/docker-stack-deploy@v1.4.0 with: remote_host: ${{ secrets.REMOTE_HOST }} remote_user: ${{ secrets.REMOTE_USER }} @@ -96,10 +176,10 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout codebase - uses: actions/checkout@v2 + uses: actions/checkout@v7 - name: Deploy - uses: kitconcept/docker-stack-deploy@v1.0.1 + uses: kitconcept/docker-stack-deploy@v1.4.0 with: registry: "ghcr.io" username: ${{ secrets.GHCR_USERNAME }} @@ -109,7 +189,6 @@ jobs: remote_private_key: ${{ secrets.REMOTE_PRIVATE_KEY }} stack_file: "stacks/plone.yml" stack_name: "plone-live" - stack_param: "foo" ``` ## Using the Docker Image diff --git a/action.yml b/action.yml index 273a036..683b568 100644 --- a/action.yml +++ b/action.yml @@ -52,7 +52,11 @@ inputs: required: false default: "" env_file: - description: "Additional environment variables in the format VAR_01=VALUE\nVAR_02=VALUE" + description: "Additional environment variables, as content, in the format VAR_01=VALUE\nVAR_02=VALUE. Mutually exclusive with env_file_path." + required: false + default: "" + env_file_path: + description: "Path to a file containing additional environment variables, one VAR=VALUE per line. Mutually exclusive with env_file." required: false default: "" debug: @@ -80,5 +84,6 @@ runs: STACK_NAME: ${{ inputs.stack_name }} STACK_PARAM: ${{ inputs.stack_param }} ENV_FILE: ${{ inputs.env_file }} + ENV_FILE_PATH: ${{ inputs.env_file_path }} DEBUG: ${{ inputs.debug }} SCALE_AFTER: ${{ inputs.scale_after }} diff --git a/news/2.documentation b/news/2.documentation new file mode 100644 index 0000000..6fb21b5 --- /dev/null +++ b/news/2.documentation @@ -0,0 +1 @@ +Documented how values reach the stack file, with worked examples for `env_file`, `env_file_path` and `stack_param`, and noted that `stack_param` is a one-variable special case of `env_file`. @ericof diff --git a/news/3.feature b/news/3.feature new file mode 100644 index 0000000..680ab05 --- /dev/null +++ b/news/3.feature @@ -0,0 +1 @@ +Added an `env_file_path` input, which reads the additional environment variables from a file instead of taking them as content. `env_file` keeps its current meaning, so nothing breaks; supplying both is an error. @ericof diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 12b42e7..7681350 100755 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -5,7 +5,9 @@ set -e SSH_DIR="/root/.ssh" SSH_KEY="${SSH_DIR}/docker" KNOWN_HOSTS="${SSH_DIR}/known_hosts" -ENV_FILE_PATH="/root/.env" +# Where the `env_file` input's content is materialised before being parsed. +# Distinct from the ENV_FILE_PATH input, which is a path supplied by the user. +ENV_FILE_DEST="/root/.env" OPTS=("--with-registry-auth" "--resolve-image=${RESOLVE_IMAGE:-always}") [ "${PRUNE:-0}" = "1" ] && OPTS+=("--prune") @@ -32,9 +34,10 @@ configure_ssh_key() { ssh-add "${SSH_KEY}" } -configure_env_file() { - printf '%s' "$ENV_FILE" > "${ENV_FILE_PATH}" - env_file_len=$(grep -cv -e '^#' -e '^$' "${ENV_FILE_PATH}" || true) +# Parse a NAME=VALUE file and export every entry. $1 is the file to read. +load_env_file() { + local source_file="$1" env_file_len line + env_file_len=$(grep -cv -e '^#' -e '^$' "${source_file}" || true) if [[ ${env_file_len} -gt 0 ]]; then echo "Environment Variables: Additional values" if [ "${DEBUG}" != "0" ]; then @@ -47,8 +50,9 @@ configure_env_file() { # Values are taken verbatim, matching `docker --env-file`: quotes in the # file are part of the value, not delimiters around it. # - # ENV_FILE has no trailing newline, so the `-n` test is what keeps the - # final line from being dropped by read's non-zero exit. + # The ENV_FILE input has no trailing newline, so the `-n` test is what + # keeps the final line from being dropped by read's non-zero exit. A file + # read through env_file_path usually does end in a newline; both work. while IFS= read -r line || [ -n "${line}" ]; do case "${line}" in ''|\#*) continue ;; @@ -58,13 +62,32 @@ configure_env_file() { exit 1 fi export "${line?}" - done < "${ENV_FILE_PATH}" + done < "${source_file}" if [ "${DEBUG}" != "0" ]; then echo "Environment vars after: $(env|wc -l)" fi fi } +# The env_file input carries the variables themselves, so materialise them +# before parsing. +configure_env_file() { + printf '%s' "$ENV_FILE" > "${ENV_FILE_DEST}" + load_env_file "${ENV_FILE_DEST}" +} + +# The env_file_path input names a file to read instead. Relative paths resolve +# against the workspace, which is the working directory of a docker action -- +# the same way the stack_file input works. +configure_env_file_path() { + if [ ! -f "${ENV_FILE_PATH}" ]; then + echo "${ENV_FILE_PATH} does not exist." + exit 1 + fi + echo "Environment Variables: Reading ${ENV_FILE_PATH}" + load_env_file "${ENV_FILE_PATH}" +} + configure_ssh_host() { ssh-keyscan -p "${REMOTE_PORT}" "${REMOTE_HOST}" > "${KNOWN_HOSTS}" chmod 600 "${KNOWN_HOSTS}" @@ -110,7 +133,16 @@ fi [ -z ${DEBUG+x} ] && export DEBUG="0" # ADDITIONAL ENV VARIABLES -if [[ -z "${ENV_FILE}" ]]; then +# env_file carries the variables themselves; env_file_path names a file to read +# them from. Accepting both at once would leave the precedence undefined, so it +# is rejected rather than guessed at. +if [[ -n "${ENV_FILE}" ]] && [[ -n "${ENV_FILE_PATH}" ]]; then + echo "Inputs env_file and env_file_path are mutually exclusive!" + exit 1 +fi +if [[ -n "${ENV_FILE_PATH}" ]]; then + configure_env_file_path; +elif [[ -z "${ENV_FILE}" ]]; then export ENV_FILE="" else configure_env_file; diff --git a/tests/entrypoint.bats b/tests/entrypoint.bats index cce23e6..14fea67 100644 --- a/tests/entrypoint.bats +++ b/tests/entrypoint.bats @@ -121,7 +121,7 @@ export_from_env_file() { ENV_FILE="${body}" WANT="${var}" \ bash -c " source '${ENTRYPOINT}' - ENV_FILE_PATH=\"\${HOME}/.env\" + ENV_FILE_DEST=\"\${HOME}/.env\" configure_env_file >/dev/null 2>&1 printf '%s' \"\${!WANT}\" " @@ -187,7 +187,7 @@ export_from_env_file() { run env -i PATH="${PATH}" HOME="${BATS_TEST_TMPDIR}" ENV_FILE=".env" \ bash -c " source '${ENTRYPOINT}' - ENV_FILE_PATH=\"\${HOME}/.env\" + ENV_FILE_DEST=\"\${HOME}/.env\" configure_env_file " [ "$status" -eq 1 ] @@ -198,13 +198,124 @@ export_from_env_file() { run env -i PATH="${PATH}" HOME="${BATS_TEST_TMPDIR}" ENV_FILE="MY-VAR=1" \ bash -c " source '${ENTRYPOINT}' - ENV_FILE_PATH=\"\${HOME}/.env\" + ENV_FILE_DEST=\"\${HOME}/.env\" configure_env_file " [ "$status" -eq 1 ] [[ "$output" == *"is not in NAME=VALUE format"* ]] } +# --- env_file_path (issue #3) ------------------------------------------------- + +# Write a file with the given content, run configure_env_file_path against it, +# and print the resulting value of one variable. +# +# The value is bracketed because bats strips trailing newlines from $output, +# which would hide a value that wrongly kept one. +export_from_env_file_path() { + local body="$1" var="$2" + local file="${BATS_TEST_TMPDIR}/vars.env" + printf '%s\n' "${body}" > "${file}" + run env -i PATH="${PATH}" HOME="${BATS_TEST_TMPDIR}" \ + ENV_FILE_PATH="${file}" WANT="${var}" \ + bash -c " + source '${ENTRYPOINT}' + configure_env_file_path >/dev/null 2>&1 + printf '[%s]' \"\${!WANT}\" + " +} + +@test "env_file_path exports a value read from a file (issue #3)" { + export_from_env_file_path 'DB_USER=plone' DB_USER + [ "$status" -eq 0 ] + [ "$output" = "[plone]" ] +} + +@test "env_file_path exports every entry in the file (issue #3)" { + export_from_env_file_path $'DB_USER=plone\nDB_NAME=site' DB_NAME + [ "$status" -eq 0 ] + [ "$output" = "[site]" ] +} + +@test "env_file_path ignores comments and blank lines (issue #3)" { + export_from_env_file_path $'# a comment\n\nDB_USER=plone' DB_USER + [ "$status" -eq 0 ] + [ "$output" = "[plone]" ] +} + +@test "env_file_path takes values verbatim, like env_file (issue #3)" { + # The semantics must not diverge between the two inputs: quotes are part of + # the value, matching `docker --env-file`. + export_from_env_file_path 'GREETING="hello world"' GREETING + [ "$status" -eq 0 ] + [ "$output" = '["hello world"]' ] +} + +@test "env_file_path keeps a value containing spaces (issue #3)" { + export_from_env_file_path 'SOLR_JAVA_MEM=-Xms1536m -Xmx1536m' SOLR_JAVA_MEM + [ "$status" -eq 0 ] + [ "$output" = "[-Xms1536m -Xmx1536m]" ] +} + +@test "env_file_path does not drop the last line of the file (issue #3)" { + # A real file ends in a newline, unlike the env_file input; the read loop + # has to handle both without losing or duplicating the final entry. + export_from_env_file_path $'DB_USER=plone\nDB_NAME=site\nLAST=here' LAST + [ "$status" -eq 0 ] + [ "$output" = "[here]" ] +} + +@test "env_file_path rejects a malformed line (issue #3)" { + printf '%s\n' 'not-a-pair' > "${BATS_TEST_TMPDIR}/bad.env" + run env -i PATH="${PATH}" HOME="${BATS_TEST_TMPDIR}" \ + ENV_FILE_PATH="${BATS_TEST_TMPDIR}/bad.env" \ + bash -c "source '${ENTRYPOINT}'; configure_env_file_path" + [ "$status" -eq 1 ] + [[ "$output" == *"'not-a-pair' is not in NAME=VALUE format"* ]] +} + +@test "env_file_path says which file it read (issue #3)" { + printf '%s\n' 'DB_USER=plone' > "${BATS_TEST_TMPDIR}/vars.env" + run env -i PATH="${PATH}" HOME="${BATS_TEST_TMPDIR}" \ + ENV_FILE_PATH="${BATS_TEST_TMPDIR}/vars.env" \ + bash -c "source '${ENTRYPOINT}'; configure_env_file_path" + [ "$status" -eq 0 ] + [[ "$output" == *"Environment Variables: Reading ${BATS_TEST_TMPDIR}/vars.env"* ]] +} + +@test "env_file_path fails when the file does not exist (issue #3)" { + run_entrypoint ENV_FILE_PATH=/nope/missing.env + [ "$status" -eq 1 ] + [[ "$output" == *"/nope/missing.env does not exist."* ]] +} + +@test "env_file and env_file_path together are rejected (issue #3)" { + printf '%s\n' 'DB_USER=plone' > "${BATS_TEST_TMPDIR}/vars.env" + run_entrypoint ENV_FILE="DB_NAME=site" \ + ENV_FILE_PATH="${BATS_TEST_TMPDIR}/vars.env" + [ "$status" -eq 1 ] + [[ "$output" == *"env_file and env_file_path are mutually exclusive"* ]] +} + +@test "env_file_path is read as part of the deploy flow (issue #3)" { + # The tests above call configure_env_file_path directly; this one proves the + # input is actually wired into the script's flow. + printf '%s\n' 'DB_USER=plone' > "${BATS_TEST_TMPDIR}/vars.env" + run_entrypoint ENV_FILE_PATH="${BATS_TEST_TMPDIR}/vars.env" + [ "$status" -eq 1 ] + [[ "$output" == *"Environment Variables: Reading ${BATS_TEST_TMPDIR}/vars.env"* ]] + [[ "$output" == *"Input remote_host is required!"* ]] +} + +@test "env_file still works when env_file_path is unset (issue #3)" { + # Backwards compatibility is the whole point of adding an input rather than + # changing the meaning of the existing one. + run_entrypoint ENV_FILE="DB_USER=plone" + [ "$status" -eq 1 ] + [[ "$output" == *"Environment Variables: Additional values"* ]] + [[ "$output" == *"Input remote_host is required!"* ]] +} + # --- scale_after -------------------------------------------------------------- @test "scale_after does nothing when unset" {