diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 05f7e39de..e5f53cdf7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -282,6 +282,7 @@ jobs: rpm_depends: "openssl" config_dir: ".release/linux/package/" preinstall: ".release/linux/preinst" + postinstall: ".release/linux/postinst" postremove: ".release/linux/postrm" - name: Add Linux Package names to env run: | diff --git a/.release/docker/README_docker_io.md b/.release/docker/README_docker_io.md index 5eab76624..4a58fb989 100644 --- a/.release/docker/README_docker_io.md +++ b/.release/docker/README_docker_io.md @@ -25,6 +25,16 @@ and is not suitable for actual deployment. Please see the comments within the file for more information; full configuration details can be found on Threshold's documentation site. +#### Upgrading from the HashiCorp Boundary image + +The configuration directory moved from `/boundary` to `/threshold`. For +compatibility, `/boundary` is a symlink to `/threshold` inside the image, a +`-config /boundary/config.hcl` argument (or a config mounted at +`/boundary/config.hcl`) is still honored with a deprecation warning, `boundary` +is accepted as a command name, and `BOUNDARY_*` environment variables are +mirrored to `THRESHOLD_*`. Please migrate your mounts and arguments to the +`/threshold` and `THRESHOLD_*` names. + ### Postgres The usage instructions in this README assume you have an external postgres database (version 12 or greater) to run threshold server with. diff --git a/.release/docker/docker-entrypoint.sh b/.release/docker/docker-entrypoint.sh index f9b4b6293..8c12c0583 100755 --- a/.release/docker/docker-entrypoint.sh +++ b/.release/docker/docker-entrypoint.sh @@ -22,6 +22,54 @@ for legacy_name in $(env | sed -n 's/^\(BOUNDARY_[A-Za-z0-9_]*\)=.*/\1/p'); do fi done +# Backwards compatibility: the legacy HashiCorp Boundary image kept its config +# in /boundary/config.hcl and ran the "boundary" binary. Accept both. +# (The *_TEST_* overrides exist only for .release/docker/test-entrypoint.sh.) +NEW_CONFIG_PATH=${THRESHOLD_ENTRYPOINT_TEST_NEW:-/threshold/config.hcl} +LEGACY_CONFIG_PATH=${THRESHOLD_ENTRYPOINT_TEST_LEGACY:-/boundary/config.hcl} +STOCK_CONFIG_COPY=${THRESHOLD_ENTRYPOINT_TEST_STOCK:-/usr/share/threshold/config.hcl.default} + +legacy_warn() { + >&2 echo "WARNING: $1 (deprecated Boundary-era path); please move to /threshold/config.hcl" +} + +# Prints the config path that should actually be used for a requested -config +# path. Prefers /threshold/config.hcl, falling back to /boundary/config.hcl. +resolve_config_path() { + case "$1" in + "$NEW_CONFIG_PATH") + # The image ships a stock demo config at /threshold/config.hcl. If it is + # still the stock one and the user supplied their own legacy config + # (for example by mounting it at /boundary/config.hcl on an image + # where /boundary is a real directory), use theirs. + if [ -f "$LEGACY_CONFIG_PATH" ] && ! [ "$LEGACY_CONFIG_PATH" -ef "$NEW_CONFIG_PATH" ] \ + && [ -f "$STOCK_CONFIG_COPY" ] && cmp -s "$NEW_CONFIG_PATH" "$STOCK_CONFIG_COPY"; then + legacy_warn "using $LEGACY_CONFIG_PATH" + echo "$LEGACY_CONFIG_PATH" + return + fi + ;; + "$LEGACY_CONFIG_PATH") + if [ -f "$LEGACY_CONFIG_PATH" ]; then + legacy_warn "using $LEGACY_CONFIG_PATH" + elif [ -f "$NEW_CONFIG_PATH" ]; then + legacy_warn "$LEGACY_CONFIG_PATH not found, falling back to $NEW_CONFIG_PATH" + echo "$NEW_CONFIG_PATH" + return + fi + ;; + esac + echo "$1" +} + +# Accept the legacy command name: "docker run boundary ..." (or an +# entrypoint/command override that invokes boundary) runs threshold. +if [ "$1" = 'boundary' ]; then + >&2 echo "WARNING: the 'boundary' command is deprecated; use 'threshold' (running threshold)" + shift + set -- threshold "$@" +fi + # If the user is trying to run Threshold directly with some arguments, then # pass them to Threshold. if [ "${1:0:1}" = '-' ]; then @@ -38,6 +86,26 @@ elif threshold --help "$1" 2>&1 | grep -q "threshold $1"; then set -- threshold "$@" fi +# Rewrite -config arguments (see resolve_config_path). +if [ "$1" = 'threshold' ]; then + _n=$# + _prev="" + while [ "$_n" -gt 0 ]; do + _a=$1 + shift + _n=$((_n - 1)) + case "$_prev" in + -config | --config) _new=$(resolve_config_path "$_a") ;; + *) _new=$_a ;; + esac + case "$_a" in + -config=* | --config=*) _new="${_a%%=*}=$(resolve_config_path "${_a#*=}")" ;; + esac + _prev=$_a + set -- "$@" "$_new" + done +fi + # If we are running Threshold, make sure it executes as the proper user. if [ "$1" = 'threshold' ]; then is_root=0 @@ -51,6 +119,10 @@ if [ "$1" = 'threshold' ]; then if [ "$(stat -c %u /threshold)" != "$(id -u threshold)" ]; then chown -R threshold:threshold /threshold || echo "Could not chown /threshold (may not have appropriate permissions)" fi + # Legacy config dir, when it is a real directory (not the symlink to /threshold) + if [ -d /boundary ] && [ ! -L /boundary ] && [ "$(stat -c %u /boundary)" != "$(id -u threshold)" ]; then + chown -R threshold:threshold /boundary || echo "Could not chown /boundary (may not have appropriate permissions)" + fi else >&2 echo "SKIP_CHOWN not set but running non-root; skipping chown /threshold" fi diff --git a/.release/docker/test-entrypoint.sh b/.release/docker/test-entrypoint.sh new file mode 100755 index 000000000..fefd19f6c --- /dev/null +++ b/.release/docker/test-entrypoint.sh @@ -0,0 +1,107 @@ +#!/bin/bash +# Self-contained test for the legacy-path handling in docker-entrypoint.sh. +# +# Runs the real entrypoint with a fake "threshold" binary and temporary +# config paths (via the THRESHOLD_ENTRYPOINT_TEST_* overrides). No Docker or +# root needed. The entrypoint is written for busybox/ash; bash is used here +# because it accepts the same ${var:off:len} syntax. +# +# Usage: .release/docker/test-entrypoint.sh + +set -u + +HERE=$(cd "$(dirname "$0")" && pwd) +ENTRY="$HERE/docker-entrypoint.sh" +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +FAILS=0 +PASSES=0 +check() { # check DESC EXPECTED ACTUAL + if [ "$2" = "$3" ]; then + PASSES=$((PASSES + 1)) + else + FAILS=$((FAILS + 1)) + echo "FAIL: $1" >&2 + echo " expected: $2" >&2 + echo " actual: $3" >&2 + fi +} + +mkdir -p "$WORK/bin" "$WORK/new" "$WORK/legacy" +# Fake threshold: print the argument vector on one line, "|"-separated. +cat >"$WORK/bin/threshold" <<'EOF' +#!/bin/sh +case "$1" in --help) [ "$2" = threshold ] || echo "threshold $2"; exit 0 ;; esac +IFS='|' +echo "ARGS:$*" +EOF +cat >"$WORK/bin/su-exec" <<'EOF' +#!/bin/sh +shift +exec "$@" +EOF +chmod 755 "$WORK/bin/threshold" "$WORK/bin/su-exec" + +NEWCFG="$WORK/new/config.hcl" +LEGCFG="$WORK/legacy/config.hcl" +STOCK="$WORK/stock.hcl" + +run() { # run ARGS... ; prints " || " + PATH="$WORK/bin:$PATH" SKIP_CHOWN=1 SKIP_SETCAP=1 \ + THRESHOLD_ENTRYPOINT_TEST_NEW="$NEWCFG" \ + THRESHOLD_ENTRYPOINT_TEST_LEGACY="$LEGCFG" \ + THRESHOLD_ENTRYPOINT_TEST_STOCK="$STOCK" \ + bash "$ENTRY" "$@" 2>"$WORK/err" | grep '^ARGS:' +} +stderr() { cat "$WORK/err"; } + +echo "stock demo config" >"$STOCK" +cp "$STOCK" "$NEWCFG" + +# 1. default invocation, stock config, no legacy file -> unchanged, no warning +out=$(run server -config "$NEWCFG") +check "default path kept" "ARGS:server|-config|$NEWCFG" "$out" +check "no warning by default" "" "$(stderr | grep -c deprecated | grep -v '^0$')" + +# 2. user's own /threshold config (differs from stock) wins over a legacy file +echo "user threshold config" >"$NEWCFG" +echo "user legacy config" >"$LEGCFG" +out=$(run server -config "$NEWCFG") +check "custom threshold config preferred" "ARGS:server|-config|$NEWCFG" "$out" + +# 3. stock /threshold config + user legacy config -> legacy used with warning +cp "$STOCK" "$NEWCFG" +out=$(run server -config "$NEWCFG") +check "legacy config used when threshold is stock" "ARGS:server|-config|$LEGCFG" "$out" +check "deprecation warning printed" "1" "$(stderr | grep -c 'WARNING.*Boundary-era')" + +# 4. explicit legacy path that exists -> used with warning +out=$(run server -config "$LEGCFG") +check "explicit legacy path kept" "ARGS:server|-config|$LEGCFG" "$out" +check "explicit legacy warns" "1" "$(stderr | grep -c 'WARNING')" + +# 5. explicit legacy path that does not exist -> fall back to threshold path +rm -f "$LEGCFG" +out=$(run server -config "$LEGCFG") +check "missing legacy falls back to threshold" "ARGS:server|-config|$NEWCFG" "$out" + +# 6. -config=PATH form +echo "user legacy config" >"$LEGCFG" +out=$(run server -config="$LEGCFG") +check "-config=path form" "ARGS:server|-config=$LEGCFG" "$out" +rm -f "$LEGCFG" +out=$(run server -config="$LEGCFG") +check "-config=path fallback" "ARGS:server|-config=$NEWCFG" "$out" + +# 7. legacy command name +out=$(run boundary version) +check "boundary command accepted" "ARGS:version" "$out" +check "boundary command warns" "1" "$(stderr | grep -c "'boundary' command is deprecated")" + +# 8. unrelated args untouched +out=$(run database init -config "$NEWCFG" -skip-auth-method-creation) +check "other args preserved" "ARGS:database|init|-config|$NEWCFG|-skip-auth-method-creation" "$out" + +echo "passed: $PASSES, failed: $FAILS" +[ "$FAILS" -eq 0 ] diff --git a/.release/linux/package/usr/lib/systemd/system/threshold.service b/.release/linux/package/usr/lib/systemd/system/threshold.service index 1c2926ba3..5529a5b68 100644 --- a/.release/linux/package/usr/lib/systemd/system/threshold.service +++ b/.release/linux/package/usr/lib/systemd/system/threshold.service @@ -5,6 +5,9 @@ StartLimitIntervalSec=60 StartLimitBurst=3 [Service] +# Legacy HashiCorp Boundary environment file (BOUNDARY_* variables are still +# honored). Read first so that threshold.env takes precedence. +EnvironmentFile=-/etc/boundary.d/boundary.env EnvironmentFile=-/etc/threshold.d/threshold.env User=threshold Group=threshold diff --git a/.release/linux/postinst b/.release/linux/postinst new file mode 100755 index 000000000..f80cfb519 --- /dev/null +++ b/.release/linux/postinst @@ -0,0 +1,163 @@ +#!/bin/sh +# Threshold package post-install script (deb "postinst" / rpm "%post"). +# +# Completes an in-place upgrade from a HashiCorp Boundary install. Safe to run +# repeatedly; never deletes user configuration or data: +# +# 1. If the legacy "boundary" user exists and there is no "threshold" user, +# run the service as "boundary" through a systemd drop-in, so existing +# data/log directory ownership keeps working. +# 2. Provide a "boundary" command: /usr/bin/boundary -> threshold, only if +# /usr/bin/boundary does not exist. +# 3. If the legacy boundary.service is enabled/active: stop it, carry its +# enablement over to threshold.service, and start threshold.service if the +# old service was running (rolling back to boundary.service if that fails). +# 4. Make "systemctl boundary" address threshold.service by adding a +# boundary.service alias symlink, unless something already exists there. +# +# THRESHOLD_PKG_ROOT exists only so the logic can be exercised by +# .release/linux/test/test-package-scripts.sh without root; it is empty in +# normal use. + +set -eu + +ROOT="${THRESHOLD_PKG_ROOT:-}" +NEW_USER="threshold" +LEGACY_USER="boundary" +UNIT="threshold.service" +LEGACY_UNIT="boundary.service" +SYSTEMD_ETC="$ROOT/etc/systemd/system" +DROPIN_DIR="$SYSTEMD_ETC/threshold.service.d" +DROPIN="$DROPIN_DIR/10-legacy-boundary-user.conf" +DROPIN_MARK="# Managed by the Threshold package (legacy boundary user reuse)" + +say() { + echo "threshold postinst: $*" >&2 +} + +user_exists() { + id -u "$1" >/dev/null 2>&1 +} + +have_systemd() { + command -v systemctl >/dev/null 2>&1 && [ -d "$ROOT/run/systemd/system" ] +} + +# Print the Id= systemd resolves a unit name to (follows aliases). +unit_id() { + systemctl show -p Id "$1" 2>/dev/null | sed -n 's/^Id=//p' +} + +unit_load_state() { + systemctl show -p LoadState "$1" 2>/dev/null | sed -n 's/^LoadState=//p' +} + +# Where the packaged threshold.service lives (merged-usr or not). +packaged_unit_path() { + for _p in "$ROOT/usr/lib/systemd/system/$UNIT" "$ROOT/lib/systemd/system/$UNIT"; do + if [ -f "$_p" ]; then + echo "${_p#"$ROOT"}" + return 0 + fi + done + return 1 +} + +# --- 1. legacy service user ------------------------------------------------- +if user_exists "$LEGACY_USER" && ! user_exists "$NEW_USER"; then + _group=$(id -gn "$LEGACY_USER" 2>/dev/null || echo "$LEGACY_USER") + _content="$DROPIN_MARK +[Service] +User=$LEGACY_USER +Group=$_group" + if [ -e "$DROPIN" ] && ! grep -qF "$DROPIN_MARK" "$DROPIN"; then + say "leaving existing $DROPIN untouched (not managed by this package)" + else + mkdir -p "$DROPIN_DIR" + _tmp="$DROPIN.tmp.$$" + printf '%s\n' "$_content" >"$_tmp" + mv -f "$_tmp" "$DROPIN" + say "service will run as existing user '$LEGACY_USER' ($DROPIN)" + fi +fi + +# --- 2. "boundary" command ---------------------------------------------------- +_bin="$ROOT/usr/bin/boundary" +if [ ! -e "$_bin" ] && [ ! -L "$_bin" ]; then + if [ -e "$ROOT/usr/bin/threshold" ]; then + ln -s threshold "$_bin" + fi +elif [ ! -L "$_bin" ]; then + say "/usr/bin/boundary is still the HashiCorp Boundary binary (old package installed); leaving it" + say "after removing the old package run: ln -s threshold /usr/bin/boundary" +fi + +# --- 3 + 4. legacy systemd service ---------------------------------------------- +if have_systemd; then + _alias="$SYSTEMD_ETC/$LEGACY_UNIT" + + if [ -L "$_alias" ]; then + # Already aliased (previous run): nothing to migrate. + : + elif [ -e "$_alias" ]; then + say "$_alias is a custom unit; not touching the legacy service." + say "move it away and re-run the package configuration to migrate." + else + _id=$(unit_id "$LEGACY_UNIT") + _load=$(unit_load_state "$LEGACY_UNIT") + _legacy_present=0 + _was_enabled=0 + _was_active=0 + if [ "$_id" = "$LEGACY_UNIT" ] && [ "$_load" = "loaded" ]; then + _legacy_present=1 + if systemctl is-enabled --quiet "$LEGACY_UNIT" 2>/dev/null; then + _was_enabled=1 + fi + if systemctl is-active --quiet "$LEGACY_UNIT" 2>/dev/null; then + _was_active=1 + fi + fi + + # Only act on upgrades: a legacy unit or legacy config is present. + if [ "$_legacy_present" = 1 ] || [ -d "$ROOT/etc/boundary.d" ]; then + if [ "$_was_active" = 1 ]; then + say "stopping $LEGACY_UNIT" + systemctl stop "$LEGACY_UNIT" || say "could not stop $LEGACY_UNIT" + fi + if [ "$_was_enabled" = 1 ]; then + systemctl disable "$LEGACY_UNIT" >/dev/null 2>&1 || say "could not disable $LEGACY_UNIT" + fi + + # Alias symlink so `systemctl restart boundary` reaches threshold.service. + if _unit_path=$(packaged_unit_path); then + mkdir -p "$SYSTEMD_ETC" + ln -s "$_unit_path" "$_alias" + fi + systemctl daemon-reload || true + + if [ "$_was_enabled" = 1 ]; then + systemctl enable "$UNIT" >/dev/null 2>&1 || say "could not enable $UNIT" + fi + if [ "$_was_active" = 1 ]; then + if systemctl start "$UNIT"; then + say "$LEGACY_UNIT migrated to $UNIT (config: /etc/threshold.d)" + else + say "FAILED to start $UNIT; rolling back to $LEGACY_UNIT" + systemctl disable "$UNIT" >/dev/null 2>&1 || true + rm -f "$_alias" + systemctl daemon-reload || true + if [ "$_was_enabled" = 1 ]; then + systemctl enable "$LEGACY_UNIT" >/dev/null 2>&1 || true + fi + systemctl start "$LEGACY_UNIT" || say "could not restart $LEGACY_UNIT" + fi + elif [ "$_was_enabled" = 1 ]; then + say "$LEGACY_UNIT enablement carried over to $UNIT (not started: it was stopped)" + fi + fi + fi + # Pick up a new/changed user drop-in even when no migration was needed. + systemctl daemon-reload || true +fi + +exit 0 diff --git a/.release/linux/postrm b/.release/linux/postrm old mode 100644 new mode 100755 index ecf8f5079..53e6f61d7 --- a/.release/linux/postrm +++ b/.release/linux/postrm @@ -1,8 +1,55 @@ -#!/bin/bash +#!/bin/sh +# Threshold package post-remove script (deb "postrm" / rpm "%postun"). +# +# deb: $1 is remove|purge|upgrade|... rpm: $1 is 0 (erase) or 1+ (upgrade) +# +# On final removal this removes only what the Threshold package scripts +# themselves created: the managed systemd drop-in, the /usr/bin/boundary and +# /etc/systemd/system/boundary.service symlinks (only if they point at +# Threshold), and on purge the "threshold" user. User configuration, data and +# the legacy "boundary" user are never touched. +# +# THRESHOLD_PKG_ROOT exists only so the logic can be exercised by +# .release/linux/test/test-package-scripts.sh without root; it is empty in +# normal use. -if [ "$1" = "purge" ] -then - userdel threshold +set -eu + +ROOT="${THRESHOLD_PKG_ROOT:-}" +SYSTEMD_ETC="$ROOT/etc/systemd/system" +DROPIN_DIR="$SYSTEMD_ETC/threshold.service.d" +DROPIN="$DROPIN_DIR/10-legacy-boundary-user.conf" +DROPIN_MARK="# Managed by the Threshold package (legacy boundary user reuse)" + +# rm a symlink only if its target's basename matches $2. +rm_link_if() { + if [ -L "$1" ]; then + _t=$(readlink "$1" || true) + case "${_t##*/}" in + "$2") rm -f "$1" ;; + esac + fi +} + +case "${1:-}" in +remove | purge | 0) + rm_link_if "$ROOT/usr/bin/boundary" threshold + rm_link_if "$SYSTEMD_ETC/boundary.service" threshold.service + rm_link_if "$SYSTEMD_ETC/multi-user.target.wants/threshold.service" threshold.service + if [ -f "$DROPIN" ] && grep -qF "$DROPIN_MARK" "$DROPIN"; then + rm -f "$DROPIN" + rmdir "$DROPIN_DIR" 2>/dev/null || true + fi + if command -v systemctl >/dev/null 2>&1 && [ -d "$ROOT/run/systemd/system" ]; then + systemctl daemon-reload >/dev/null 2>&1 || true + fi + ;; +esac + +if [ "${1:-}" = "purge" ]; then + if id -u threshold >/dev/null 2>&1; then + userdel threshold || true + fi fi -exit 0 \ No newline at end of file +exit 0 diff --git a/.release/linux/preinst b/.release/linux/preinst old mode 100644 new mode 100755 index 5f0fcaf9a..6f04424ce --- a/.release/linux/preinst +++ b/.release/linux/preinst @@ -1,13 +1,98 @@ -#!/bin/bash +#!/bin/sh +# Threshold package pre-install script (deb "preinst" / rpm "%pre"). +# +# Runs before the package files are unpacked. It is safe to run repeatedly and +# never deletes or overwrites anything: +# +# * Service user: if a HashiCorp Boundary "boundary" user already exists and +# there is no "threshold" user yet, no new user is created. The postinst +# script makes the service run as the existing "boundary" user so that its +# data/log directory ownership keeps working. +# * Configuration: files from /etc/boundary.d are COPIED to /etc/threshold.d +# (boundary.hcl -> threshold.hcl, boundary.env -> threshold.env, everything +# else keeps its name). An existing file in /etc/threshold.d is never +# replaced, and the originals in /etc/boundary.d are left untouched. +# +# THRESHOLD_PKG_ROOT exists only so the logic can be exercised by +# .release/linux/test/test-package-scripts.sh without root; it is empty in +# normal use. set -eu -USER="threshold" +ROOT="${THRESHOLD_PKG_ROOT:-}" +NEW_USER="threshold" +LEGACY_USER="boundary" +LEGACY_DIR="$ROOT/etc/boundary.d" +NEW_DIR="$ROOT/etc/threshold.d" -if ! id -u $USER > /dev/null 2>&1; then - useradd \ - --system \ - --user-group \ - --shell /bin/false \ - $USER -fi \ No newline at end of file +say() { + echo "threshold preinst: $*" >&2 +} + +user_exists() { + id -u "$1" >/dev/null 2>&1 +} + +# Map a top-level legacy file name to its Threshold name. +map_name() { + case "$1" in + boundary.hcl) echo "threshold.hcl" ;; + boundary.env) echo "threshold.env" ;; + *) echo "$1" ;; + esac +} + +# copy_tree SRC_DIR DST_DIR TOPLEVEL(0/1) +# Copies entries that do not exist yet in DST_DIR; never overwrites. +copy_tree() { + _src=$1 + _dst=$2 + _top=$3 + for _f in "$_src"/* "$_src"/.[!.]*; do + if [ ! -e "$_f" ] && [ ! -L "$_f" ]; then + continue + fi + _name=${_f##*/} + if [ "$_top" = 1 ]; then + _name=$(map_name "$_name") + fi + _target="$_dst/$_name" + if [ -d "$_f" ] && [ ! -L "$_f" ]; then + if [ -d "$_target" ]; then + copy_tree "$_f" "$_target" 0 + elif [ -e "$_target" ] || [ -L "$_target" ]; then + say "not replacing existing $_target" + else + cp -pR "$_f" "$_target" + say "copied $_f -> $_target" + fi + elif [ -e "$_target" ] || [ -L "$_target" ]; then + # Already present (user's own file, or a previous run): keep it. + : + else + cp -pP "$_f" "$_target" + say "copied $_f -> $_target" + fi + done +} + +# --- service user ----------------------------------------------------------- +if ! user_exists "$NEW_USER"; then + if user_exists "$LEGACY_USER"; then + say "existing '$LEGACY_USER' user found; reusing it instead of creating '$NEW_USER'" + else + useradd \ + --system \ + --user-group \ + --shell /bin/false \ + "$NEW_USER" + fi +fi + +# --- configuration migration ------------------------------------------------ +if [ -d "$LEGACY_DIR" ]; then + mkdir -p "$NEW_DIR" + copy_tree "$LEGACY_DIR" "$NEW_DIR" 1 +fi + +exit 0 diff --git a/.release/linux/test/test-package-scripts.sh b/.release/linux/test/test-package-scripts.sh new file mode 100755 index 000000000..f1261ec86 --- /dev/null +++ b/.release/linux/test/test-package-scripts.sh @@ -0,0 +1,267 @@ +#!/bin/sh +# Self-contained test for the Linux package scripts (preinst/postinst/postrm). +# +# Simulates an old HashiCorp Boundary layout in a temporary directory (fake +# /etc/boundary.d, a "boundary" user, a running boundary.service) using the +# THRESHOLD_PKG_ROOT prefix and fake id/useradd/userdel/systemctl commands. +# Needs neither root nor systemd. +# +# Usage: .release/linux/test/test-package-scripts.sh + +set -eu + +HERE=$(cd "$(dirname "$0")" && pwd) +PKG_DIR=$(cd "$HERE/.." && pwd) +PREINST="$PKG_DIR/preinst" +POSTINST="$PKG_DIR/postinst" +POSTRM="$PKG_DIR/postrm" +SH=${TEST_SH:-sh} + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT INT TERM + +FAILS=0 +PASSES=0 + +pass() { PASSES=$((PASSES + 1)); } +fail() { + FAILS=$((FAILS + 1)) + echo "FAIL: $*" >&2 +} +assert() { # assert DESC cmd... + _d=$1 + shift + if "$@"; then pass; else fail "$_d"; fi +} +refute() { # refute DESC cmd... + _d=$1 + shift + if "$@"; then fail "$_d"; else pass; fi +} +file_has() { grep -qF -- "$2" "$1"; } +file_eq() { [ "$(cat "$1")" = "$2" ]; } + +# --- fake commands ------------------------------------------------------------ +BIN="$WORK/bin" +mkdir -p "$BIN" + +cat >"$BIN/id" <<'EOF' +#!/bin/sh +# fake id: id -u NAME | id -gn NAME ; users in $FAKE/users as name:group +mode=$1 +name=$2 +line=$(grep "^$name:" "$FAKE/users" 2>/dev/null) || exit 1 +case "$mode" in +-u) echo 1000 ;; +-gn) echo "${line#*:}" ;; +esac +EOF +cat >"$BIN/useradd" <<'EOF' +#!/bin/sh +for a in "$@"; do name=$a; done +echo "$name:$name" >>"$FAKE/users" +echo "useradd $name" >>"$FAKE/calls" +EOF +cat >"$BIN/userdel" <<'EOF' +#!/bin/sh +grep -v "^$1:" "$FAKE/users" >"$FAKE/users.new" || true +mv "$FAKE/users.new" "$FAKE/users" +echo "userdel $1" >>"$FAKE/calls" +EOF +cat >"$BIN/systemctl" <<'EOF' +#!/bin/sh +# fake systemctl backed by files in $FAKE (state) and $THRESHOLD_PKG_ROOT +resolve() { + if [ -L "$THRESHOLD_PKG_ROOT/etc/systemd/system/$1" ]; then + _t=$(readlink "$THRESHOLD_PKG_ROOT/etc/systemd/system/$1") + echo "${_t##*/}" + else + echo "$1" + fi +} +echo "systemctl $*" >>"$FAKE/calls" +case "$1" in +show) + u=$(resolve "$4") + case "$3" in + Id) echo "Id=$u" ;; + LoadState) + if [ -f "$THRESHOLD_PKG_ROOT/usr/lib/systemd/system/$u" ]; then + echo "LoadState=loaded" + else + echo "LoadState=not-found" + fi + ;; + esac + ;; +is-enabled) u=$(resolve "$3"); [ -e "$FAKE/enabled/$u" ] ;; +is-active) u=$(resolve "$3"); [ -e "$FAKE/active/$u" ] ;; +enable) u=$(resolve "$2"); : >"$FAKE/enabled/$u" ;; +disable) u=$(resolve "$2"); rm -f "$FAKE/enabled/$u" ;; +start) + u=$(resolve "$2") + [ -e "$FAKE/fail_start/$u" ] && exit 1 + : >"$FAKE/active/$u" + ;; +stop) u=$(resolve "$2"); rm -f "$FAKE/active/$u" ;; +daemon-reload) : ;; +esac +exit $? +EOF +chmod 755 "$BIN"/* + +# --- scenario helpers ----------------------------------------------------------- +new_root() { # new_root NAME -> sets ROOT and FAKE, creates skeleton + ROOT="$WORK/$1/root" + FAKE="$WORK/$1/fake" + mkdir -p "$ROOT/etc" "$ROOT/usr/bin" "$ROOT/usr/lib/systemd/system" \ + "$ROOT/etc/systemd/system" "$ROOT/run/systemd/system" \ + "$FAKE/enabled" "$FAKE/active" "$FAKE/fail_start" + : >"$FAKE/users" + : >"$FAKE/calls" + # What the package itself would have unpacked. + : >"$ROOT/usr/bin/threshold" + echo "[Service]" >"$ROOT/usr/lib/systemd/system/threshold.service" + export THRESHOLD_PKG_ROOT="$ROOT" FAKE +} + +run_script() { # run_script SCRIPT [ARGS...] + _s=$1 + shift + PATH="$BIN:$PATH" "$SH" "$_s" "$@" >"$WORK/out.log" 2>&1 +} + +add_legacy_layout() { + mkdir -p "$ROOT/etc/boundary.d/tls" + echo 'controller { name = "mine" }' >"$ROOT/etc/boundary.d/boundary.hcl" + echo 'BOUNDARY_POSTGRES_URL=secret' >"$ROOT/etc/boundary.d/boundary.env" + echo 'worker { name = "w" }' >"$ROOT/etc/boundary.d/worker.hcl" + echo 'KEY' >"$ROOT/etc/boundary.d/tls/server.key" + chmod 600 "$ROOT/etc/boundary.d/tls/server.key" + echo "boundary:boundary" >>"$FAKE/users" + echo "[Service]" >"$ROOT/usr/lib/systemd/system/boundary.service" +} + +# ============================================================================== +echo "== scenario 1: upgrade from Boundary (running + enabled), existing threshold.hcl" +new_root s1 +add_legacy_layout +: >"$FAKE/enabled/boundary.service" +: >"$FAKE/active/boundary.service" +mkdir -p "$ROOT/etc/threshold.d" +echo 'USER-OWNED' >"$ROOT/etc/threshold.d/threshold.hcl" + +assert "preinst succeeds" run_script "$PREINST" install +assert "threshold.hcl NOT clobbered" file_eq "$ROOT/etc/threshold.d/threshold.hcl" "USER-OWNED" +assert "boundary.env migrated to threshold.env" file_has "$ROOT/etc/threshold.d/threshold.env" "BOUNDARY_POSTGRES_URL=secret" +assert "worker.hcl copied under same name" file_has "$ROOT/etc/threshold.d/worker.hcl" 'name = "w"' +assert "subdirectory copied" file_has "$ROOT/etc/threshold.d/tls/server.key" "KEY" +assert "originals kept: boundary.hcl" test -f "$ROOT/etc/boundary.d/boundary.hcl" +assert "originals kept: boundary.env" test -f "$ROOT/etc/boundary.d/boundary.env" +refute "no duplicate threshold user created" grep -q '^useradd' "$FAKE/calls" +refute "no boundary.hcl name left in threshold.d" test -e "$ROOT/etc/threshold.d/boundary.hcl" + +assert "postinst succeeds" run_script "$POSTINST" configure +assert "drop-in written" file_has "$ROOT/etc/systemd/system/threshold.service.d/10-legacy-boundary-user.conf" "User=boundary" +assert "drop-in group" file_has "$ROOT/etc/systemd/system/threshold.service.d/10-legacy-boundary-user.conf" "Group=boundary" +assert "boundary command symlink" test -L "$ROOT/usr/bin/boundary" +assert "boundary symlink target is threshold" test "$(readlink "$ROOT/usr/bin/boundary")" = "threshold" +assert "boundary.service alias symlink" test -L "$ROOT/etc/systemd/system/boundary.service" +assert "legacy service stopped" test ! -e "$FAKE/active/boundary.service" +assert "threshold enabled" test -e "$FAKE/enabled/threshold.service" +assert "threshold started" test -e "$FAKE/active/threshold.service" +refute "legacy unit not left enabled as itself" grep -q 'enable boundary.service' "$FAKE/calls" + +: >"$FAKE/calls" +assert "postinst idempotent (2nd run)" run_script "$POSTINST" configure +refute "2nd run does not stop/start anything" grep -qE 'systemctl (stop|start|enable|disable)' "$FAKE/calls" +assert "preinst idempotent (2nd run)" run_script "$PREINST" upgrade +assert "threshold.hcl still not clobbered" file_eq "$ROOT/etc/threshold.d/threshold.hcl" "USER-OWNED" + +echo "== scenario 1b: postrm remove/purge cleans only generated pieces" +assert "postrm remove" run_script "$POSTRM" remove +refute "boundary command symlink removed" test -L "$ROOT/usr/bin/boundary" +refute "alias symlink removed" test -L "$ROOT/etc/systemd/system/boundary.service" +refute "drop-in removed" test -e "$ROOT/etc/systemd/system/threshold.service.d/10-legacy-boundary-user.conf" +assert "user config untouched after remove" test -f "$ROOT/etc/boundary.d/boundary.hcl" +assert "migrated config untouched after remove" test -f "$ROOT/etc/threshold.d/threshold.env" +assert "postrm purge (no threshold user: no userdel)" run_script "$POSTRM" purge +refute "boundary user never deleted" grep -q 'userdel boundary' "$FAKE/calls" + +# ============================================================================== +echo "== scenario 2: fresh install, no Boundary" +new_root s2 +assert "preinst succeeds" run_script "$PREINST" install +assert "threshold user created" grep -q '^useradd threshold' "$FAKE/calls" +refute "no /etc/threshold.d created by migration" test -e "$ROOT/etc/threshold.d" +assert "postinst succeeds" run_script "$POSTINST" configure +refute "no drop-in" test -e "$ROOT/etc/systemd/system/threshold.service.d" +refute "no boundary.service alias on fresh install" test -L "$ROOT/etc/systemd/system/boundary.service" +assert "boundary command symlink still provided" test -L "$ROOT/usr/bin/boundary" +refute "threshold not auto-enabled on fresh install" test -e "$FAKE/enabled/threshold.service" +assert "postrm purge deletes threshold user" run_script "$POSTRM" purge +assert "userdel threshold called" grep -q '^userdel threshold' "$FAKE/calls" + +# ============================================================================== +echo "== scenario 3: boundary service enabled but threshold start fails -> rollback" +new_root s3 +add_legacy_layout +: >"$FAKE/enabled/boundary.service" +: >"$FAKE/active/boundary.service" +: >"$FAKE/fail_start/threshold.service" +assert "preinst succeeds" run_script "$PREINST" install +assert "postinst still exits 0" run_script "$POSTINST" configure +assert "legacy service running again" test -e "$FAKE/active/boundary.service" +assert "legacy service enabled again" test -e "$FAKE/enabled/boundary.service" +refute "threshold left disabled" test -e "$FAKE/enabled/threshold.service" +refute "alias removed on rollback" test -L "$ROOT/etc/systemd/system/boundary.service" +assert "rollback explained" file_has "$WORK/out.log" "rolling back" + +# ============================================================================== +echo "== scenario 4: enabled but stopped boundary service is only carried over" +new_root s4 +add_legacy_layout +: >"$FAKE/enabled/boundary.service" +assert "preinst" run_script "$PREINST" install +assert "postinst" run_script "$POSTINST" configure +assert "threshold enabled" test -e "$FAKE/enabled/threshold.service" +refute "threshold not started" test -e "$FAKE/active/threshold.service" + +# ============================================================================== +echo "== scenario 5: custom regular-file /etc/systemd/system/boundary.service is left alone" +new_root s5 +add_legacy_layout +: >"$FAKE/enabled/boundary.service" +: >"$FAKE/active/boundary.service" +echo "CUSTOM" >"$ROOT/etc/systemd/system/boundary.service" +assert "preinst" run_script "$PREINST" install +assert "postinst" run_script "$POSTINST" configure +assert "custom unit untouched" file_eq "$ROOT/etc/systemd/system/boundary.service" "CUSTOM" +assert "legacy service still running" test -e "$FAKE/active/boundary.service" +refute "threshold not started" test -e "$FAKE/active/threshold.service" + +# ============================================================================== +echo "== scenario 6: old boundary package binary present is not replaced" +new_root s6 +add_legacy_layout +echo "OLDBIN" >"$ROOT/usr/bin/boundary" +assert "preinst" run_script "$PREINST" install +assert "postinst" run_script "$POSTINST" configure +refute "boundary binary not turned into symlink" test -L "$ROOT/usr/bin/boundary" +assert "boundary binary content kept" file_eq "$ROOT/usr/bin/boundary" "OLDBIN" +assert "postrm remove does not delete the old binary" run_script "$POSTRM" remove +assert "old binary still there" test -f "$ROOT/usr/bin/boundary" + +# ============================================================================== +echo "== scenario 7: threshold user already exists alongside boundary -> no drop-in" +new_root s7 +add_legacy_layout +echo "threshold:threshold" >>"$FAKE/users" +assert "preinst" run_script "$PREINST" install +assert "postinst" run_script "$POSTINST" configure +refute "no drop-in when threshold user exists" test -e "$ROOT/etc/systemd/system/threshold.service.d" + +# ============================================================================== +echo +echo "passed: $PASSES, failed: $FAILS" +[ "$FAILS" -eq 0 ] diff --git a/Dockerfile b/Dockerfile index 726367bad..19bf03df7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,6 +29,13 @@ ADD .release/docker/config.hcl /threshold/config.hcl RUN chown -R threshold:threshold /threshold/ RUN chmod -R 640 /threshold/* +# Legacy HashiCorp Boundary compatibility: /boundary -> /threshold and a +# "boundary" command alias (prints a deprecation notice on stderr). +RUN mkdir -p /usr/share/threshold && \ + cp /threshold/config.hcl /usr/share/threshold/config.hcl.default && \ + ln -s /threshold /boundary && \ + ln -s threshold /bin/boundary + EXPOSE 9200 9201 9202 VOLUME /threshold/ @@ -80,6 +87,13 @@ RUN setcap cap_ipc_lock=+ep /bin/$BIN_NAME RUN chown -R ${NAME}:${NAME} /threshold RUN chmod -R 640 /threshold/* +# Legacy HashiCorp Boundary compatibility: /boundary -> /threshold and a +# "boundary" command alias (prints a deprecation notice on stderr). +RUN mkdir -p /usr/share/threshold && \ + cp /threshold/config.hcl /usr/share/threshold/config.hcl.default && \ + ln -s /threshold /boundary && \ + ln -s ${BIN_NAME} /bin/boundary + EXPOSE 9200 9201 9202 VOLUME /threshold/ diff --git a/README.md b/README.md index 573ef2ed6..44aeadcc8 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,117 @@ Other fallbacks: Tokens are always written using the new names. +## Upgrading from Boundary + +Threshold is designed as an in-place replacement for HashiCorp Boundary CE: +the same database, the same configuration files, and a binary that can still be +invoked as `boundary`. + +### Compatibility guarantees + +These are unchanged from upstream Boundary CE, so a mixed fleet of Boundary and +Threshold controllers and workers interoperates, and you can upgrade (and roll +back) one node at a time: + +- **Database schema**: the schema migrations are untouched and the + `boundary_schema_version` table keeps its name. Threshold runs against an + existing Boundary database as-is (`threshold database migrate` applies the + same migrations Boundary would). +- **Wire protocol**: protobuf packages, gRPC service names and the `/v1` HTTP + API paths are unchanged (only Go import paths were renamed). Versions are + still reported to peers with the `Boundary v` prefix. +- **Plugin protocol**: the plugin magic cookie + (`HASHICORP_BOUNDARY_PLUGIN` / `boundary-plugin`) is unchanged, so existing + `boundary-plugin-*` binaries keep working. +- **Configuration file format** (HCL) is unchanged. + +Do not run `database migrate` from a newer Threshold than your other nodes +understand; as with Boundary, upgrade controllers before workers and follow the +usual rolling-upgrade guidance for schema migrations. + +### Linux packages (deb/rpm) + +1. Back up `/etc/boundary.d` and take a database backup. +2. Install the Threshold package. Do not `purge` the old `boundary` package + (removal is fine); purging runs its scripts, which may delete the `boundary` + user that Threshold will reuse. Installing Threshold while the old package is + still installed is supported. +3. The package scripts then, automatically: + - **copy** `/etc/boundary.d` to `/etc/threshold.d` (`boundary.hcl` becomes + `threshold.hcl`, `boundary.env` becomes `threshold.env`; subdirectories such + as TLS material are copied as-is). Existing files in `/etc/threshold.d` are + never overwritten and the originals are never modified or deleted; + - keep reading `/etc/boundary.d/boundary.env` as a fallback environment file + (`threshold.env` takes precedence), so `BOUNDARY_*` variables keep working; + - reuse the existing `boundary` service user and group instead of creating a + `threshold` user (through a managed systemd drop-in, + `/etc/systemd/system/threshold.service.d/10-legacy-boundary-user.conf`), so + ownership of your data, log and key directories keeps working; + - if `boundary.service` was enabled and/or running, stop and disable it, + enable `threshold.service`, and start it if the old service was running. + If `threshold.service` fails to start, the old service is restored; + - make `systemctl restart boundary` (and `status`, `stop`, ...) address + `threshold.service` via a `boundary.service` alias symlink in + `/etc/systemd/system`, and provide `/usr/bin/boundary` as a symlink to + `threshold` if no such file exists. The `boundary` command prints a + one-line deprecation notice to **stderr** only (silence it with + `THRESHOLD_NO_DEPRECATION_NOTICE=1`); stdout, flags and exit codes are + identical to `threshold`. +4. Verify: `systemctl status threshold`, `threshold version`. + +What is **not** migrated automatically: + +- Paths *inside* your configuration files are not rewritten. A config that + refers to `/etc/boundary.d/...` or `/var/lib/boundary` keeps working because + those locations are left in place; update them at your own pace. +- A custom `/etc/systemd/system/boundary.service` unit (a regular file) is left + alone and the legacy service is not touched; migrate it by hand. +- If `/usr/bin/boundary` is still the old binary from the Boundary package, it is + left alone. After removing the old package: `ln -s threshold /usr/bin/boundary`. +- Distribution-specific package-manager behavior such as `--force-confnew` (dpkg) + can replace `/etc/threshold.d` defaults; your originals remain in + `/etc/boundary.d`. +- Data directories, the database, KMS keys and the client cache are not moved + (the client cache directory is covered by the table above). + +Rollback: stop `threshold.service`, remove the `boundary.service` alias +symlink (`/etc/systemd/system/boundary.service`) and the drop-in, reinstall the +old Boundary package, and `systemctl enable --now boundary`. Because the +database schema is unchanged and `/etc/boundary.d` was never modified, nothing +else needs to be restored. If you ran a Threshold release that applied schema +migrations newer than your Boundary version, restore the database backup +instead. + +### Container image + +The image's configuration directory moved from `/boundary` to `/threshold` +(default command: `threshold server -config /threshold/config.hcl`). For +compatibility the image: + +- contains `/boundary` as a symlink to `/threshold`, so existing + `-v "$(pwd)":/boundary/` mounts land in the right place; +- falls back to `/boundary/config.hcl` (printing a deprecation warning) when + `-config /boundary/config.hcl` is requested, or when the stock demo config is + still in place and a config exists at `/boundary/config.hcl`; +- accepts `boundary ...` as the command and `BOUNDARY_*` environment variables + (mirrored to `THRESHOLD_*`). + +Plan to switch your mounts, `-config` arguments and environment variables to +the `/threshold` and `THRESHOLD_*` names; the compatibility paths print +warnings and may be removed in a future major release. + +### Other installs (binary / zip) + +Replace the `boundary` binary with `threshold` (or keep the file name +`boundary`, which behaves identically). Existing `BOUNDARY_*` variables, +`~/.boundary` and stored credentials are picked up as described above. + +### Development mode + +`threshold dev` creates the `ssh.threshold.dev` and `postgres.threshold.dev` +aliases and, for compatibility, the legacy `ssh.boundary.dev` and +`postgres.boundary.dev` aliases pointing at the same targets. + ## Building ``` diff --git a/cmd/threshold/main.go b/cmd/threshold/main.go index 55fae1d5a..f6f6f8fd7 100644 --- a/cmd/threshold/main.go +++ b/cmd/threshold/main.go @@ -10,5 +10,13 @@ import ( ) func main() { + // When invoked through the legacy "boundary" name (a symlink shipped in + // packages/images), print a one-line deprecation notice to stderr. Behavior + // is otherwise identical and stdout is never touched. + if len(os.Args) > 0 { + if notice := cmd.LegacyNameNotice(os.Args[0], os.Getenv); notice != "" { + _, _ = os.Stderr.WriteString(notice) + } + } os.Exit(cmd.Run(os.Args[1:])) } diff --git a/internal/cmd/base/initial_resources.go b/internal/cmd/base/initial_resources.go index 6612e320d..0796940a4 100644 --- a/internal/cmd/base/initial_resources.go +++ b/internal/cmd/base/initial_resources.go @@ -615,9 +615,11 @@ func (b *Server) CreateInitialTargetWithHostSources(ctx context.Context) (target } // Create targets that can be connected to using an alias. The three targets created are: -// - "postgres.boundary.dev": the Threshold dev postgres instance. Uses brokered credentials +// - "postgres.threshold.dev": the Threshold dev postgres instance. Uses brokered credentials +// (the legacy "postgres.boundary.dev" alias is also created) // - "www.hashicorp.com": a web target -// - "ssh.boundary.dev": A localhost ssh target +// - "ssh.threshold.dev": A localhost ssh target (the legacy "ssh.boundary.dev" +// alias is also created) func (b *Server) CreateInitialTargetsWithAlias(ctx context.Context) error { rw := db.New(b.Database) @@ -679,14 +681,9 @@ func (b *Server) createSshAliasTarget(ctx context.Context, targetRepo *target.Re return err } - sshAlias := "ssh.boundary.dev" - a, err := aliastar.NewAlias(ctx, "global", sshAlias, aliastar.WithDestinationId(t.GetPublicId())) - if err != nil { - return fmt.Errorf("failed to create alias object %w", err) - } - _, err = aliasRepo.CreateAlias(ctx, a) - if err != nil { - return fmt.Errorf("failed to save alias to the db %w", err) + sshAlias := "ssh.threshold.dev" + if err := createDevAliases(ctx, aliasRepo, t.GetPublicId(), sshAlias, legacySshAlias); err != nil { + return err } b.InfoKeys = append(b.InfoKeys, "generated ssh target with alias") @@ -694,6 +691,29 @@ func (b *Server) createSshAliasTarget(ctx context.Context, targetRepo *target.Re return nil } +// Legacy dev-mode alias names used by upstream HashiCorp Boundary. They are +// still created, pointing at the same targets, so existing scripts and muscle +// memory keep working. +const ( + legacySshAlias = "ssh.boundary.dev" + legacyPostgresAlias = "postgres.boundary.dev" +) + +// createDevAliases creates one alias per value, all pointing at the given +// destination target. +func createDevAliases(ctx context.Context, aliasRepo *aliastar.Repository, destinationId string, values ...string) error { + for _, v := range values { + a, err := aliastar.NewAlias(ctx, "global", v, aliastar.WithDestinationId(destinationId)) + if err != nil { + return fmt.Errorf("failed to create alias object %w", err) + } + if _, err = aliasRepo.CreateAlias(ctx, a); err != nil { + return fmt.Errorf("failed to save alias to the db %w", err) + } + } + return nil +} + func (b *Server) createPostgresAliasTarget(ctx context.Context, targetRepo *target.Repository, aliasRepo *aliastar.Repository, @@ -757,14 +777,9 @@ func (b *Server) createPostgresAliasTarget(ctx context.Context, return fmt.Errorf("failed to associate cred to target: %w", err) } - postgresAlias := "postgres.boundary.dev" - a, err := aliastar.NewAlias(ctx, "global", postgresAlias, aliastar.WithDestinationId(t.GetPublicId())) - if err != nil { - return fmt.Errorf("failed to create alias object %w", err) - } - _, err = aliasRepo.CreateAlias(ctx, a) - if err != nil { - return fmt.Errorf("failed to save alias to the db %w", err) + postgresAlias := "postgres.threshold.dev" + if err := createDevAliases(ctx, aliasRepo, t.GetPublicId(), postgresAlias, legacyPostgresAlias); err != nil { + return err } b.InfoKeys = append(b.InfoKeys, "generated postgres target with alias") diff --git a/internal/cmd/legacy_alias.go b/internal/cmd/legacy_alias.go new file mode 100644 index 000000000..88c38b28c --- /dev/null +++ b/internal/cmd/legacy_alias.go @@ -0,0 +1,49 @@ +// Copyright IBM Corp. 2020, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "path/filepath" + "strings" +) + +// legacyBinaryName is the name of the upstream HashiCorp Boundary binary. A +// "boundary" symlink to the threshold binary is shipped in packages and +// container images so existing scripts keep working. +const legacyBinaryName = "boundary" + +// envNoLegacyNameNotice, when set to a non-empty value, silences the +// deprecation notice printed when the binary is invoked as "boundary". +const envNoLegacyNameNotice = "THRESHOLD_NO_DEPRECATION_NOTICE" + +// invokedAsLegacyName reports whether argv0 refers to the legacy "boundary" +// binary name. Directory components and a Windows ".exe" suffix are ignored. +func invokedAsLegacyName(argv0 string) bool { + // Normalize Windows separators too, since filepath.Base on unix does not + // treat backslash as a separator. + argv0 = strings.ReplaceAll(argv0, `\`, "/") + base := strings.ToLower(filepath.Base(argv0)) + base = strings.TrimSuffix(base, ".exe") + return base == legacyBinaryName +} + +// LegacyNameNotice returns the one-line deprecation notice (including a +// trailing newline) to write to stderr when the binary was invoked as +// "boundary", or an empty string when no notice should be shown. No notice is +// shown for any other invocation name, when shell completion is running +// (COMP_LINE is set, so that a notice never corrupts the user's prompt), or +// when THRESHOLD_NO_DEPRECATION_NOTICE is set. The notice is only ever written +// to stderr so that machine-readable stdout output is unaffected. +// +// getenv is typically os.Getenv. +func LegacyNameNotice(argv0 string, getenv func(string) string) string { + if !invokedAsLegacyName(argv0) { + return "" + } + if getenv("COMP_LINE") != "" || getenv(envNoLegacyNameNotice) != "" { + return "" + } + return "Note: the 'boundary' command name is deprecated; use 'threshold' instead. " + + "(Set " + envNoLegacyNameNotice + "=1 to silence this notice.)\n" +} diff --git a/internal/cmd/legacy_alias_test.go b/internal/cmd/legacy_alias_test.go new file mode 100644 index 000000000..0569c39b9 --- /dev/null +++ b/internal/cmd/legacy_alias_test.go @@ -0,0 +1,49 @@ +// Copyright IBM Corp. 2020, 2026 +// SPDX-License-Identifier: BUSL-1.1 + +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestInvokedAsLegacyName(t *testing.T) { + t.Parallel() + tests := []struct { + argv0 string + want bool + }{ + {"boundary", true}, + {"/usr/bin/boundary", true}, + {"./boundary", true}, + {`C:\bin\boundary.exe`, true}, + {"Boundary.EXE", true}, + {"threshold", false}, + {"/usr/bin/threshold", false}, + {"boundary-worker", false}, + {"", false}, + } + for _, tt := range tests { + t.Run(tt.argv0, func(t *testing.T) { + assert.Equal(t, tt.want, invokedAsLegacyName(tt.argv0)) + }) + } +} + +func TestLegacyNameNotice(t *testing.T) { + t.Parallel() + env := func(m map[string]string) func(string) string { + return func(k string) string { return m[k] } + } + n := LegacyNameNotice("/usr/bin/boundary", env(nil)) + assert.True(t, strings.HasSuffix(n, "\n")) + assert.Equal(t, 1, strings.Count(n, "\n"), "notice must be a single line") + assert.Contains(t, n, "threshold") + + assert.Empty(t, LegacyNameNotice("/usr/bin/threshold", env(nil))) + assert.Empty(t, LegacyNameNotice("boundary", env(map[string]string{"COMP_LINE": "boundary auth"}))) + assert.Empty(t, LegacyNameNotice("boundary", env(map[string]string{envNoLegacyNameNotice: "1"}))) +}