Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ HEROKU_API_KEY="op://Private/Heroku/credential" op run -- heroku-scripts apps my
```sh
heroku-scripts apps <pipeline> <stage>
heroku-scripts pipeline-cmd <pipeline> <stage> "<heroku command>" [--concurrency=N] [--retries=N] [--no-stream] [-a] [--table|--csv]
heroku-scripts config-replace <pipeline> <stage> <VAR> <old-value> <new-value> [--concurrency=N] [--dry-run] [-a|--all] [--table|--csv] [--no-stream]
heroku-scripts pipeline-task <pipeline> <stage> <MixTask> [--concurrency=N]
heroku-scripts promote <app> <to-team> <pipeline> [--dry-run] [--yes]
```
Expand Down Expand Up @@ -146,6 +147,39 @@ last error as its record. The default is `--retries=0` (unchanged behavior).
One caveat: a mid-session drop can happen *after* the remote command started
running, so only use `--retries` with commands that are safe to run twice.

### Replace a config var's value across a stage, only where it currently matches

`config-replace` is a guarded `config:set`: it reads each app's current value
first and only writes on apps where that value is exactly the one you expect.

```sh
heroku-scripts config-replace my-pipe production SMTP_HOST smtp.old.example smtp.new.example
```

Apps that don't have the var at all are skipped (with a count on stderr, or a
`skipped: SMTP_HOST not set` record when you pass `-a`/`--all`), and apps whose
value is something else entirely are left untouched but reported, so drift
stays visible. One blind spot: `config:get` prints the same empty line for an
unset var and one set to the empty string, so a var set to `""` is treated as
not set.

```
appname;output
my-app;SMTP_HOST: smtp.new.example
my-app-worker;skipped: SMTP_HOST is "smtp.other.example" (expected "smtp.old.example")
```

Pass `--dry-run` to see what would change without setting anything — it still
reads every app's current value, so it needs credentials like a real run:

```sh
heroku-scripts config-replace my-pipe production SMTP_HOST smtp.old.example smtp.new.example --dry-run
# my-app;would set SMTP_HOST=smtp.new.example (currently smtp.old.example)
```

`--concurrency`, `--no-stream`, and `--table`/`--csv` behave exactly as in
`pipeline-cmd`.

Run a mix task on every production app, four at a time:

```sh
Expand Down
278 changes: 209 additions & 69 deletions bin/heroku-scripts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ Commands:
contain newlines and semicolons, so the CSV is for reading/grepping, not
parsing as strict CSV.

config-replace <pipeline> <stage> <VAR> <old-value> <new-value> [--concurrency=N] [--dry-run] [-a|--all] [--table|--csv] [--no-stream]
For every app in <pipeline>/<stage>, set <VAR> to <new-value> — but
only on apps whose current value is exactly <old-value>. Apps without
<VAR> are skipped (a count is reported on stderr; -a/--all emits a
"not set" record for them instead). Apps whose value differs are left
untouched but get a visible "skipped: ..." record so drift is not
silent. --dry-run reads every current value and reports what would
change without setting anything. Concurrency, streaming, and
table/CSV output behave as in pipeline-cmd. Note: config:get prints
the same empty line for an unset var and one set to the empty string,
so a var set to "" is treated as not set.

pipeline-task <pipeline> <stage> <MixTask> [--concurrency=N]
Run \`mix <MixTask>\` via \`heroku run\` against every app in
<pipeline>/<stage>. Defaults to 3 concurrent runs.
Expand Down Expand Up @@ -284,44 +296,20 @@ emit_record() {
done <<< "$output"
}

cmd_pipeline_cmd() {
[[ $# -ge 3 ]] || die "Usage: $SCRIPT_NAME pipeline-cmd <pipeline> <stage> \"<heroku command>\" [--concurrency=N] [--retries=N] [--no-stream] [-a] [--table|--csv]"
local pipeline="$1" stage="$2" command="$3"
shift 3

# Pull our own flags out before handing the rest to parse_concurrency (which
# only knows --concurrency). Streaming is the default; --no-stream buffers and
# emits everything sorted by app name at the end. Apps whose output is empty
# are skipped by default; -a/--all includes them. Output format defaults to a
# table on a terminal and CSV when piped; --table/--csv force one.
local stream=true include_empty=false format=auto retries=0
local -a opts=()
while [[ $# -gt 0 ]]; do
case "$1" in
--stream) stream=true;;
--no-stream) stream=false;;
-a|--all) include_empty=true;;
--table) format=table;;
--csv) format=csv;;
--retries=*) retries="${1#*=}";;
*) opts+=("$1");;
esac
shift
done

if [[ ! "$retries" =~ ^[0-9]+$ ]]; then
die "--retries must be a non-negative integer"
fi

local concurrency=3
if [[ ${#opts[@]} -gt 0 ]]; then
concurrency="$(parse_concurrency "${opts[@]}")" || exit 1
fi

resolve_heroku_api_key

local apps
apps="$(require_apps "$pipeline" "$stage")" || exit 1
# Shared skeleton for the commands that fan a per-app worker out across a
# pipeline stage: chunked parallelism, streaming or buffered record emission,
# table/CSV rendering, and the skipped-apps summary. pipeline-cmd and
# config-replace differ only in what they do per app, so that part comes in as
# a worker function name. Worker-specific parameters (the heroku command, the
# config var, ...) are the calling command's locals, which bash's dynamic
# scoping keeps visible inside the worker.
#
# The worker is called with one argument (the app name), prints the app's
# record on stdout, and returns non-zero to skip the app: no record is emitted
# and the app is counted in the stderr summary, rendered as
# "<count> $skip_summary".
run_pipeline_workers() {
local worker="$1" apps="$2" concurrency="$3" stream="$4" format="$5" skip_summary="$6"

# Resolve auto -> table on a terminal, CSV when piped. The table's left column
# is sized from the (already known) app list, so table output still streams.
Expand All @@ -345,9 +333,9 @@ cmd_pipeline_cmd() {
# shellcheck disable=SC2064
trap "trap - INT TERM EXIT; kill \$(jobs -p) 2>/dev/null || true; rm -rf '$tmpdir'; exit 130" INT TERM

# Workers drop a marker file here for each app skipped due to empty output, so
# the parent can report a count after they all finish. A dotfile dir, so the
# --no-stream collection glob (`$tmpdir/*`) never picks it up.
# Workers drop a marker file here for each app they skip, so the parent can
# report a count after they all finish. A dotfile dir, so the --no-stream
# collection glob (`$tmpdir/*`) never picks it up.
mkdir "$tmpdir/.skipped"

if [[ "$render" == "table" ]]; then
Expand All @@ -365,35 +353,37 @@ cmd_pipeline_cmd() {
# heroku/psql in the subshell will happily read from the loop's heredoc
# and consume the lines that the next `read` was supposed to see — apps
# get skipped and their names get fed to heroku as stray positional args.
local in_flight=0
local in_flight=0 app
while IFS= read -r app <&3; do
[[ -z "$app" ]] && continue
(
# The subshell inherits `set -e`. We deliberately turn it off here so a
# non-zero exit from heroku (e.g. pg:psql against an app without a DB)
# is captured into the output column rather than killing the subshell
# before it can emit its record.
# non-zero exit from heroku inside the worker (e.g. pg:psql against an
# app without a DB) is captured into the output column rather than
# killing the subshell before it can emit its record.
set +e
output="$(heroku_for_app_with_retries "$app" "$command" "$retries")"
if [[ -z "$output" && "$include_empty" != "true" ]]; then
# Empty output: record the skip for the summary and emit no record.
: > "$tmpdir/.skipped/$app"
elif [[ "$stream" == "true" ]]; then
# Print as soon as this app finishes. Serialize with an atomic mkdir
# lock so a record streams out in one piece (emit_record may write
# several lines for multi-line/table output) even when apps finish at
# the same time; contention is brief. Ignore SIGPIPE so that if the
# reader goes away (e.g. `| head`) the writes merely fail instead of
# killing the worker mid-record and leaving the lock held; emit_record
# breaks on the first failed write so the lock is released promptly.
trap '' PIPE
while ! mkdir "$tmpdir/.lock" 2>/dev/null; do sleep 0.05; done
emit_record "$render" "$width" "$app" "$output"
rmdir "$tmpdir/.lock"
if output="$("$worker" "$app")"; then
if [[ "$stream" == "true" ]]; then
# Print as soon as this app finishes. Serialize with an atomic mkdir
# lock so a record streams out in one piece (emit_record may write
# several lines for multi-line/table output) even when apps finish at
# the same time; contention is brief. Ignore SIGPIPE so that if the
# reader goes away (e.g. `| head`) the writes merely fail instead of
# killing the worker mid-record and leaving the lock held; emit_record
# breaks on the first failed write so the lock is released promptly.
trap '' PIPE
while ! mkdir "$tmpdir/.lock" 2>/dev/null; do sleep 0.05; done
emit_record "$render" "$width" "$app" "$output"
rmdir "$tmpdir/.lock"
else
# Buffered: store the raw output (filename is the app name) and
# format it sorted at the end.
printf '%s' "$output" > "$tmpdir/$app"
fi
else
# Buffered: store the raw output (filename is the app name) and format
# it sorted at the end.
printf '%s' "$output" > "$tmpdir/$app"
# Non-zero from the worker means "skip this app": record it for the
# summary and emit no record.
: > "$tmpdir/.skipped/$app"
fi
) </dev/null &
# Pre-increment so the expression value (and thus exit status) is the
Expand Down Expand Up @@ -421,14 +411,14 @@ cmd_pipeline_cmd() {
done
fi

# Report how many apps were skipped for empty output, on stderr so it never
# pollutes the data stream piped from stdout. Separated from the output by a
# blank line and rendered dim + italic — but only when stderr is a terminal
# and NO_COLOR is unset, so redirected/piped output stays plain text.
# Report how many apps the workers skipped, on stderr so it never pollutes
# the data stream piped from stdout. Separated from the output by a blank
# line and rendered dim + italic — but only when stderr is a terminal and
# NO_COLOR is unset, so redirected/piped output stays plain text.
local skipped
skipped="$(find "$tmpdir/.skipped" -type f | wc -l | tr -d ' ')"
if (( skipped > 0 )); then
local msg="$skipped app(s) with empty output skipped (use -a/--all to include them)"
local msg="$skipped $skip_summary"
if [[ -t 2 && -z "${NO_COLOR:-}" ]]; then
printf '\n\033[2;3m%s\033[0m\n' "$msg" >&2
else
Expand All @@ -437,6 +427,153 @@ cmd_pipeline_cmd() {
fi
}

# Per-app worker for pipeline-cmd: run the heroku command and use its combined
# output as the record. Empty output means the app has nothing to report (e.g.
# config:get for an unset var), so it is skipped unless -a/--all was passed.
# $command, $retries, and $include_empty are cmd_pipeline_cmd's locals.
pipeline_cmd_worker() {
local app="$1" output
output="$(heroku_for_app_with_retries "$app" "$command" "$retries")"
if [[ -z "$output" && "$include_empty" != "true" ]]; then
return 1
fi
printf '%s' "$output"
}

cmd_pipeline_cmd() {
[[ $# -ge 3 ]] || die "Usage: $SCRIPT_NAME pipeline-cmd <pipeline> <stage> \"<heroku command>\" [--concurrency=N] [--retries=N] [--no-stream] [-a] [--table|--csv]"
local pipeline="$1" stage="$2" command="$3"
shift 3

# Pull our own flags out before handing the rest to parse_concurrency (which
# only knows --concurrency). Streaming is the default; --no-stream buffers and
# emits everything sorted by app name at the end. Apps whose output is empty
# are skipped by default; -a/--all includes them. Output format defaults to a
# table on a terminal and CSV when piped; --table/--csv force one.
local stream=true include_empty=false format=auto retries=0
local -a opts=()
while [[ $# -gt 0 ]]; do
case "$1" in
--stream) stream=true;;
--no-stream) stream=false;;
-a|--all) include_empty=true;;
--table) format=table;;
--csv) format=csv;;
--retries=*) retries="${1#*=}";;
*) opts+=("$1");;
esac
shift
done

if [[ ! "$retries" =~ ^[0-9]+$ ]]; then
die "--retries must be a non-negative integer"
fi

local concurrency=3
if [[ ${#opts[@]} -gt 0 ]]; then
concurrency="$(parse_concurrency "${opts[@]}")" || exit 1
fi

resolve_heroku_api_key

local apps
apps="$(require_apps "$pipeline" "$stage")" || exit 1

run_pipeline_workers pipeline_cmd_worker "$apps" "$concurrency" "$stream" "$format" \
"app(s) with empty output skipped (use -a/--all to include them)"
}

# Per-app worker for config-replace. Reads the current value with a real argv
# call — <VAR> and the app are discrete values, so routing them through
# heroku_for_app's eval would invite word-splitting and glob expansion — and
# only writes when it is exactly $old_value. $var, $old_value, $new_value,
# $dry_run, and $include_unset are cmd_config_replace's locals.
config_replace_worker() {
local app="$1" current status
# Capture config:get's raw output and exit status before filtering — piping
# straight into strip_heroku_noise (which ends in `|| true`) would lose the
# status. A failed lookup (no access, app gone) must never have its error
# text compared against <old-value>: in the pathological case a matching
# message would authorize a write to an app whose value was never read.
current="$(heroku config:get "$var" -a "$app" 2>&1)"
status=$?
current="$(strip_heroku_noise <<< "$current")"
if (( status != 0 )); then
printf 'error: %s' "$current"
return 0
fi
# config:get prints an empty line when the var is unset — and also when it
# is set to the empty string; its output cannot tell the two apart, so an
# empty-string value is treated as not set. The command substitution
# collapses that line to an empty string.
if [[ -z "$current" ]]; then
if [[ "$include_unset" == "true" ]]; then
printf 'skipped: %s not set' "$var"
return 0
fi
return 1
fi
if [[ "$current" != "$old_value" ]]; then
# A different value is left alone, but visibly: a silent skip would hide
# apps that have drifted from the value the operator expected.
printf 'skipped: %s is "%s" (expected "%s")' "$var" "$current" "$old_value"
return 0
fi
if [[ "$dry_run" == "true" ]]; then
printf 'would set %s=%s (currently %s)' "$var" "$new_value" "$old_value"
return 0
fi
# argv form, never eval: the new value may contain spaces or shell
# metacharacters and must reach heroku as one word. Captured first rather
# than piped straight into strip_heroku_noise: under pipefail a failed
# config:set would make the pipeline — and so this worker — return non-zero,
# which the runner counts as a skip, silently misreporting a failed write as
# "var not set". Success or failure, the combined output is the record.
local result
result="$(heroku config:set "$var=$new_value" -a "$app" 2>&1)"
strip_heroku_noise <<< "$result"
}

cmd_config_replace() {
[[ $# -ge 5 ]] || die "Usage: $SCRIPT_NAME config-replace <pipeline> <stage> <VAR> <old-value> <new-value> [--concurrency=N] [--dry-run] [-a|--all] [--table|--csv] [--no-stream]"
local pipeline="$1" stage="$2" var="$3" old_value="$4" new_value="$5"
shift 5

# Same flag surface as pipeline-cmd, plus --dry-run (read every current
# value, write nothing). -a/--all here means "include apps that don't have
# <VAR> set", recording them as "skipped: <VAR> not set" instead of omitting
# them.
local stream=true include_unset=false format=auto dry_run=false
local -a opts=()
while [[ $# -gt 0 ]]; do
case "$1" in
--stream) stream=true;;
--no-stream) stream=false;;
-a|--all) include_unset=true;;
--table) format=table;;
--csv) format=csv;;
--dry-run) dry_run=true;;
*) opts+=("$1");;
esac
shift
done

local concurrency=3
if [[ ${#opts[@]} -gt 0 ]]; then
concurrency="$(parse_concurrency "${opts[@]}")" || exit 1
fi

# A dry run still reads every app's current value via config:get, so it
# needs credentials just like a real run.
resolve_heroku_api_key

local apps
apps="$(require_apps "$pipeline" "$stage")" || exit 1

run_pipeline_workers config_replace_worker "$apps" "$concurrency" "$stream" "$format" \
"app(s) without $var skipped (use -a/--all to include them)"
}

cmd_pipeline_task() {
[[ $# -ge 3 ]] || die "Usage: $SCRIPT_NAME pipeline-task <pipeline> <stage> <MixTask> [--concurrency=N]"
local pipeline="$1" stage="$2" task="$3"
Expand Down Expand Up @@ -543,6 +680,9 @@ main() {
pipeline-cmd)
cmd_pipeline_cmd "$@"
;;
config-replace)
cmd_config_replace "$@"
;;
pipeline-task)
cmd_pipeline_task "$@"
;;
Expand Down
Loading
Loading