From dc1faaf8277691ea5f2e798481e3a3b1a0bcfd07 Mon Sep 17 00:00:00 2001 From: T3ST3ST3R0N Date: Tue, 20 Oct 2026 07:24:32 +0330 Subject: [PATCH 1/4] feat(pg-node): add --dev to install the node dev image `pg-node install --dev` sets the compose image to pasarguard/node:dev, the image the node repo builds from its dev branch, so testers no longer have to edit docker-compose.yml by hand. It skips the GitHub release lookup, prints a note that the image is an unreleased test build, and is mutually exclusive with --version and --pre-release, like pasarguard.sh. The compose image rewrite moves into set_node_image_tag so it can be tested. `pg-node update` already keeps the compose tag, so a dev node pulls the newest dev build on update. node-serviced keeps installing its own latest release. --- README.md | 3 + docs/cli-reference.md | 1 + pg-node.sh | 36 ++++++++- tests/unit_pgnode.sh | 174 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 210 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1c0cda2..bf6f0d2 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,9 @@ sudo bash pg-node.sh install # Multi-instance node with custom name sudo bash pg-node.sh install --name node-de1 --self-signed + +# Testers only: the unreleased pasarguard/node:dev image (built from the node dev branch) +sudo bash pg-node.sh install --dev ``` Once installed, manage the node using the global `pg-node` command: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index b199332..6a99cc4 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -205,6 +205,7 @@ Installs or re-installs a worker node instance. **Options:** - `-v`, `--version `: Install specific node image version. - `--pre-release`: Install latest pre-release image. +- `--dev`: Install the `pasarguard/node:dev` image, built from the node `dev` branch. Unreleased, for testing only; `pg-node update` pulls the newest dev build. Cannot be combined with `--version` or `--pre-release`. - `--name `: Custom instance name (creates `/opt/` and `/var/lib/`). - `--override`: Force overwrite existing node instance. - `--api-key `: Pre-configure API authentication key. diff --git a/pg-node.sh b/pg-node.sh index ec5201c..e84dfcc 100755 --- a/pg-node.sh +++ b/pg-node.sh @@ -711,6 +711,16 @@ read_and_save_file() { done } +# Replace the image tag of the node service in a compose file (e.g. :latest -> :dev). +# Arguments: +# $1 - Image tag (a release tag such as v0.5.4, or dev). +# $2 - Path to the docker-compose.yml to edit in place. +set_node_image_tag() { + local tag="$1" + local compose_file="$2" + yq eval ".services[\"node\"].image = (.services[\"node\"].image | sub(\":.*$\"; \":${tag}\"))" -i "$compose_file" 2>/dev/null +} + # Download compose files, set up certificates, configure .env, and deploy the node. install_node() { local node_version=$1 @@ -887,7 +897,7 @@ install_node() { sed -i "s|^SSL_KEY_FILE *=.*|SSL_KEY_FILE= ${ssl_key_env}|" "$APP_DIR/.env" if [ "$node_version" != "latest" ]; then colorized_echo cyan " Command: yq eval ...image = ...:${node_version}..." - if yq eval ".services[\"$service_name\"].image = (.services[\"$service_name\"].image | sub(\":.*$\"; \":${node_version}\"))" -i "$APP_DIR/docker-compose.yml" 2>/dev/null; then + if set_node_image_tag "$node_version" "$APP_DIR/docker-compose.yml"; then colorized_echo green " ✓ Docker image version set to: ${node_version}" else colorized_echo yellow " ⚠ Failed to set image version (may not be critical)" @@ -1076,7 +1086,7 @@ install_command() { case $key in -v | --version) if [[ "$node_version_set" == "true" ]]; then - colorized_echo red "Error: Cannot use --pre-release and --version options simultaneously." + colorized_echo red "Error: Cannot use --pre-release, --dev and --version options simultaneously." exit 1 fi node_version="$2" @@ -1085,13 +1095,22 @@ install_command() { ;; --pre-release) if [[ "$node_version_set" == "true" ]]; then - colorized_echo red "Error: Cannot use --pre-release and --version options simultaneously." + colorized_echo red "Error: Cannot use --pre-release, --dev and --version options simultaneously." exit 1 fi node_version="pre-release" node_version_set="true" shift ;; + --dev) + if [[ "$node_version_set" == "true" ]]; then + colorized_echo red "Error: Cannot use --pre-release, --dev and --version options simultaneously." + exit 1 + fi + node_version="dev" + node_version_set="true" + shift + ;; --name) # --name is handled globally; ignore here to prevent unknown option errors shift 2 @@ -1182,6 +1201,10 @@ install_command() { check_version_exists() { local version=$1 repo_url="https://api.github.com/repos/PasarGuard/node/releases" + # dev is the Docker image built from the node dev branch, not a GitHub release + if [ "$version" == "dev" ]; then + return 0 + fi if [ "$version" == "latest" ]; then latest_tag=$(curl -s ${repo_url}/latest | jq -r '.tag_name') # Check if there is any stable release of node v1 @@ -1214,12 +1237,16 @@ install_command() { fi } # Check if the version is valid and exists - if [[ "$node_version" == "latest" || "$node_version" == "pre-release" || "$node_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + if [[ "$node_version" == "latest" || "$node_version" == "pre-release" || "$node_version" == "dev" || "$node_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then if check_version_exists "$node_version"; then colorized_echo cyan "================================" colorized_echo cyan "Installing PasarGuard Node" colorized_echo cyan "Version: $node_version" colorized_echo cyan "================================" + if [ "$node_version" == "dev" ]; then + colorized_echo yellow "Note: pasarguard/node:dev is an unreleased build of the node dev branch, meant for testing only." + colorized_echo yellow " '$APP_NAME update' pulls the newest dev build." + fi install_node "$node_version" colorized_echo green "✓ Node installation completed for version: $node_version" else @@ -2116,6 +2143,7 @@ usage() { colorized_echo cyan "Install Options:" colorized_echo yellow " -v, --version VERSION $(tput sgr0)✓ Install specific version" colorized_echo yellow " --pre-release $(tput sgr0)✓ Install pre-release version" + colorized_echo yellow " --dev $(tput sgr0)✓ Install the dev image (unreleased, for testing)" colorized_echo yellow " --name NAME $(tput sgr0)✓ Install with custom name" colorized_echo yellow " --override $(tput sgr0)✓ Override existing installation" colorized_echo yellow " --api-key KEY $(tput sgr0)✓ Set API Key" diff --git a/tests/unit_pgnode.sh b/tests/unit_pgnode.sh index 3f416c5..f102999 100644 --- a/tests/unit_pgnode.sh +++ b/tests/unit_pgnode.sh @@ -314,6 +314,180 @@ else fail "zsh completion: contains version-script and script-version" fi +# ----------------------------------------------------------------------- +# install_command version selection (--dev, --pre-release, --version) +# Host-touching steps are stubbed in a subshell; curl and install_node +# record their calls so the release lookup and chosen tag can be checked. +# ----------------------------------------------------------------------- +INSTALL_CALLS="$WORK_DIR/install-calls.log" +INSTALL_OUT="" +INSTALL_STATUS=0 + +# Run install_command with stubbed host steps; sets INSTALL_OUT and INSTALL_STATUS. +run_install_command() { + : > "$INSTALL_CALLS" + if INSTALL_OUT=$( + check_running_as_root() { :; } + print_script_execution_header() { :; } + is_node_installed() { return 1; } + detect_os() { :; } + install_package() { :; } + install_docker() { :; } + ensure_docker_compose() { :; } + install_yq() { :; } + detect_compose() { :; } + curl() { + echo "curl $*" >> "$INSTALL_CALLS" + case "$*" in + *"%{http_code}"*) printf '200' ;; + */releases/latest*) printf '{"tag_name":"v9.9.9"}' ;; + */releases) printf '[{"tag_name":"v9.9.10-beta","prerelease":true}]' ;; + *) printf '{}' ;; + esac + } + install_node() { echo "install_node $1" >> "$INSTALL_CALLS"; } + install_node_script() { :; } + install_completion() { :; } + up_node() { :; } + show_node_logs() { :; } + install_service_command() { :; } + INSTALL_SERVICE_CHOICE="n" + SERVICE_PORT="62050" + API_KEY="00000000-0000-4000-8000-000000000000" + SSL_CERT_FILE="/dev/null" + install_command "$@" 2>&1 + ); then + INSTALL_STATUS=0 + else + INSTALL_STATUS=$? + fi +} + +# Print the version install_node was called with, or nothing. +installed_version() { + sed -n 's/^install_node //p' "$INSTALL_CALLS" +} + +# Succeed if any recorded curl call hit the node GitHub releases API. +release_lookup_done() { + grep -q "api.github.com/repos/PasarGuard/node/releases" "$INSTALL_CALLS" +} + +run_install_command --dev +assert_eq "$INSTALL_STATUS" "0" "install --dev: exits 0" +assert_eq "$(installed_version)" "dev" "install --dev: installs image tag dev" +assert_false "install --dev: skips the GitHub release lookup" release_lookup_done +if [[ "$INSTALL_OUT" == *"unreleased"* ]]; then + pass "install --dev: prints the unreleased build note" +else + fail "install --dev: prints the unreleased build note" +fi + +for dev_conflict in "--dev --version v1.2.3" "--version v1.2.3 --dev" "--dev --pre-release" "--pre-release --dev" "-v v1.2.3 --dev"; do + # shellcheck disable=SC2086 + run_install_command $dev_conflict + assert_eq "$INSTALL_STATUS" "1" "install $dev_conflict: exits 1" + assert_eq "$(installed_version)" "" "install $dev_conflict: installs nothing" + if [[ "$INSTALL_OUT" == *"Cannot use --pre-release, --dev and --version options simultaneously."* ]]; then + pass "install $dev_conflict: names all three exclusive options" + else + fail "install $dev_conflict: names all three exclusive options (got: $INSTALL_OUT)" + fi +done + +# Existing selections keep their release lookup and resolved tag +run_install_command +assert_eq "$INSTALL_STATUS" "0" "install (latest): exits 0" +assert_eq "$(installed_version)" "latest" "install (latest): installs image tag latest" +assert_true "install (latest): checks releases/latest" grep -q "PasarGuard/node/releases/latest" "$INSTALL_CALLS" +if [[ "$INSTALL_OUT" != *"unreleased"* ]]; then + pass "install (latest): no dev note" +else + fail "install (latest): no dev note" +fi + +run_install_command --pre-release +assert_eq "$INSTALL_STATUS" "0" "install --pre-release: exits 0" +assert_eq "$(installed_version)" "v9.9.10-beta" "install --pre-release: installs the newest release tag" + +run_install_command --version v1.2.3 +assert_eq "$INSTALL_STATUS" "0" "install --version v1.2.3: exits 0" +assert_eq "$(installed_version)" "v1.2.3" "install --version v1.2.3: installs image tag v1.2.3" +assert_true "install --version v1.2.3: checks releases/tags/v1.2.3" grep -q "PasarGuard/node/releases/tags/v1.2.3" "$INSTALL_CALLS" + +run_install_command --version v1.2.3 --pre-release +assert_eq "$INSTALL_STATUS" "1" "install --version with --pre-release: exits 1" + +run_install_command --version 1.2 +assert_eq "$INSTALL_STATUS" "1" "install --version 1.2: rejects an invalid version" +assert_eq "$(installed_version)" "" "install --version 1.2: installs nothing" + +# ----------------------------------------------------------------------- +# set_node_image_tag (compose image rewrite used by install_node) +# Needs mikefarah yq v4 (what install_yq installs); skipped otherwise. +# ----------------------------------------------------------------------- +if command -v yq >/dev/null 2>&1 && yq --version 2>/dev/null | grep -q "mikefarah"; then + image_compose="$WORK_DIR/image-test/docker-compose.yml" + mkdir -p "$(dirname "$image_compose")" + cp "$ROOT_DIR/docker-compose/node.yml" "$image_compose" + if set_node_image_tag "dev" "$image_compose"; then + pass "set_node_image_tag: dev rewrite succeeds" + else + fail "set_node_image_tag: dev rewrite succeeds" + fi + assert_eq "$(yq eval -r '.services["node"].image' "$image_compose")" "pasarguard/node:dev" \ + "set_node_image_tag: image becomes pasarguard/node:dev" + assert_true "set_node_image_tag: compose file has 'image: pasarguard/node:dev'" \ + grep -q "^ image: pasarguard/node:dev$" "$image_compose" + assert_eq "$(yq eval -r '.services["node"].container_name' "$image_compose")" "node" \ + "set_node_image_tag: other keys untouched" + if set_node_image_tag "v0.5.4" "$image_compose"; then + pass "set_node_image_tag: release rewrite succeeds" + else + fail "set_node_image_tag: release rewrite succeeds" + fi + assert_eq "$(yq eval -r '.services["node"].image' "$image_compose")" "pasarguard/node:v0.5.4" \ + "set_node_image_tag: a dev compose file can move back to a release tag" +else + echo "(skipped set_node_image_tag: mikefarah yq v4 not available)" +fi + +# ----------------------------------------------------------------------- +# update_command keeps whatever image tag the compose file has (dev stays dev) +# ----------------------------------------------------------------------- +update_app_dir="$WORK_DIR/update-test" +mkdir -p "$update_app_dir" +cat > "$update_app_dir/docker-compose.yml" <<'EOF' +services: + node: + container_name: node + image: pasarguard/node:dev + restart: always +EOF +update_calls="$WORK_DIR/update-calls.log" +: > "$update_calls" +if ( + APP_DIR="$update_app_dir" + COMPOSE_FILE="$update_app_dir/docker-compose.yml" + set_service_paths + check_running_as_root() { :; } + detect_compose() { COMPOSE="fake_compose"; } + fake_compose() { echo "compose $*" >> "$update_calls"; } + ensure_docker_service_running() { :; } + update_node_script() { :; } + uninstall_completion() { :; } + install_completion() { :; } + update_service_if_installed() { :; } + update_command --no-update-service >/dev/null 2>&1 +); then + pass "update on a dev install: exits 0" +else + fail "update on a dev install: exits 0" +fi +assert_true "update on a dev install: pulls images" grep -q " pull$" "$update_calls" +assert_true "update on a dev install: image stays pasarguard/node:dev" \ + grep -q "^ image: pasarguard/node:dev$" "$update_app_dir/docker-compose.yml" + echo "" echo "Results: $PASS passed, $FAIL failed" [ "$FAIL" -eq 0 ] || exit 1 From ce75f5076f8f3b7f42df010fe31fda793882ce91 Mon Sep 17 00:00:00 2001 From: T3ST3ST3R0N Date: Tue, 20 Oct 2026 07:30:27 +0330 Subject: [PATCH 2/4] feat(pg-node): let update switch the node image to dev or a release `pg-node update --dev` and `pg-node update --version ` rewrite the image tag in docker-compose.yml, then run the usual pull and restart. This moves an existing node to the dev image and back without a reinstall, so the API key, .env and certificates stay as they are and the node does not have to be registered again in the panel. The two flags are mutually exclusive, combine with --no-update-service, and stop before any change when the version is invalid or not a release. --version uses the same validation as install, now shared as validate_node_version and check_node_version_exists (`latest` sets the `latest` image tag, as install does). Without either flag, update behaves exactly as before and keeps whatever tag the compose file has. --- README.md | 3 + docs/cli-reference.md | 17 +++- pg-node.sh | 187 +++++++++++++++++++++++----------- tests/unit_pgnode.sh | 228 +++++++++++++++++++++++++++++++++++------- 4 files changed, 342 insertions(+), 93 deletions(-) diff --git a/README.md b/README.md index bf6f0d2..eabb886 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,9 @@ sudo bash pg-node.sh install --name node-de1 --self-signed # Testers only: the unreleased pasarguard/node:dev image (built from the node dev branch) sudo bash pg-node.sh install --dev +# or switch an installed node to it, and back to the latest release later +sudo pg-node update --dev +sudo pg-node update --version latest ``` Once installed, manage the node using the global `pg-node` command: diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6a99cc4..f33d379 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -205,7 +205,7 @@ Installs or re-installs a worker node instance. **Options:** - `-v`, `--version `: Install specific node image version. - `--pre-release`: Install latest pre-release image. -- `--dev`: Install the `pasarguard/node:dev` image, built from the node `dev` branch. Unreleased, for testing only; `pg-node update` pulls the newest dev build. Cannot be combined with `--version` or `--pre-release`. +- `--dev`: Install the `pasarguard/node:dev` image, built from the node `dev` branch. Unreleased, for testing only; `pg-node update` pulls the newest dev build. Cannot be combined with `--version` or `--pre-release`. To move an existing node to it, use `update --dev` instead. - `--name `: Custom instance name (creates `/opt/` and `/var/lib/`). - `--override`: Force overwrite existing node instance. - `--api-key `: Pre-configure API authentication key. @@ -240,6 +240,21 @@ Streams real-time container logs. sudo pg-node logs ``` +#### `update` +Updates the `pg-node` script, pulls the node image and recreates the container. The image tag in `docker-compose.yml` is kept, so a node on `dev` stays on `dev`. Update never changes `.env`, the API key or the certificates. + +**Options:** +- `--dev`: Switch the node to the `pasarguard/node:dev` image, built from the node `dev` branch (unreleased, for testing). +- `-v`, `--version `: Switch the node to a release tag (`vX.Y.Z`), `latest` (the latest stable release) or `pre-release`. Checked against GitHub releases the same way as `install --version`. +- `--no-update-service`: Do not update the companion node-serviced daemon. + +`--dev` and `--version` cannot be combined. + +```bash +sudo pg-node update --dev +sudo pg-node update --version latest +``` + #### `core-update` Updates or switches the underlying `Xray-core` binary using [install_core.sh](../install_core.sh). - `--version `: Specify release tag (e.g. `v1.8.24` or `latest`). diff --git a/pg-node.sh b/pg-node.sh index e84dfcc..e6329ac 100755 --- a/pg-node.sh +++ b/pg-node.sh @@ -1073,6 +1073,75 @@ is_node_up() { fi } +# Check that a node image version exists as a GitHub release of PasarGuard/node. +# Arguments: +# $1 - latest, pre-release, dev, or a release tag such as v0.5.4. +# Globals: +# node_version - for pre-release, set to the newer of the latest stable and latest pre-release tags. +# Returns: +# 0 if the version can be used, 1 otherwise. +check_node_version_exists() { + local version=$1 + local repo_url="https://api.github.com/repos/PasarGuard/node/releases" + # dev is the Docker image built from the node dev branch, not a GitHub release + if [ "$version" == "dev" ]; then + return 0 + fi + if [ "$version" == "latest" ]; then + local latest_tag=$(curl -s ${repo_url}/latest | jq -r '.tag_name') + # Check if there is any stable release of node v1 + if [ "$latest_tag" == "null" ]; then + return 1 + fi + return 0 + fi + if [ "$version" == "pre-release" ]; then + local latest_stable_tag=$(curl -s "$repo_url/latest" | jq -r '.tag_name') + local latest_pre_release_tag=$(curl -s "$repo_url" | jq -r '[.[] | select(.prerelease == true)][0].tag_name') + if [ "$latest_stable_tag" == "null" ] && [ "$latest_pre_release_tag" == "null" ]; then + return 1 # No releases found at all + elif [ "$latest_stable_tag" == "null" ]; then + node_version=$latest_pre_release_tag + elif [ "$latest_pre_release_tag" == "null" ]; then + node_version=$latest_stable_tag + else + # Compare versions using sort -V + local chosen_version=$(printf "%s\n" "$latest_stable_tag" "$latest_pre_release_tag" | sort -V | tail -n 1) + node_version=$chosen_version + fi + return 0 + fi + # Check if the repos contains the version tag + if curl -s -o /dev/null -w "%{http_code}" "${repo_url}/tags/${version}" | grep -q "^200$"; then + return 0 + else + return 1 + fi +} + +# Validate node_version (format, then GitHub release) and print why it cannot be used. +# Globals: +# node_version - the requested version; pre-release is resolved to a release tag. +# Returns: +# 0 if the version can be used, 1 otherwise. +validate_node_version() { + if ! [[ "$node_version" == "latest" || "$node_version" == "pre-release" || "$node_version" == "dev" || "$node_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + colorized_echo red "✗ Invalid version format. Please enter a valid version (e.g. v1.0.0)" + return 1 + fi + if ! check_node_version_exists "$node_version"; then + colorized_echo red "✗ Version $node_version does not exist. Please enter a valid version (e.g. v0.1.2)" + return 1 + fi + return 0 +} + +# Warn that the dev image is an unreleased build and how to leave it. +print_dev_image_note() { + colorized_echo yellow "Note: pasarguard/node:dev is an unreleased build of the node dev branch, meant for testing only." + colorized_echo yellow " '$APP_NAME update' pulls the newest dev build; '$APP_NAME update --version latest' returns to the latest release." +} + # Execute node installation workflow with option parsing and setup. install_command() { check_running_as_root @@ -1197,66 +1266,19 @@ install_command() { install_yq fi detect_compose - # Function to check if a version exists in the GitHub releases - check_version_exists() { - local version=$1 - repo_url="https://api.github.com/repos/PasarGuard/node/releases" - # dev is the Docker image built from the node dev branch, not a GitHub release - if [ "$version" == "dev" ]; then - return 0 - fi - if [ "$version" == "latest" ]; then - latest_tag=$(curl -s ${repo_url}/latest | jq -r '.tag_name') - # Check if there is any stable release of node v1 - if [ "$latest_tag" == "null" ]; then - return 1 - fi - return 0 - fi - if [ "$version" == "pre-release" ]; then - local latest_stable_tag=$(curl -s "$repo_url/latest" | jq -r '.tag_name') - local latest_pre_release_tag=$(curl -s "$repo_url" | jq -r '[.[] | select(.prerelease == true)][0].tag_name') - if [ "$latest_stable_tag" == "null" ] && [ "$latest_pre_release_tag" == "null" ]; then - return 1 # No releases found at all - elif [ "$latest_stable_tag" == "null" ]; then - node_version=$latest_pre_release_tag - elif [ "$latest_pre_release_tag" == "null" ]; then - node_version=$latest_stable_tag - else - # Compare versions using sort -V - local chosen_version=$(printf "%s\n" "$latest_stable_tag" "$latest_pre_release_tag" | sort -V | tail -n 1) - node_version=$chosen_version - fi - return 0 - fi - # Check if the repos contains the version tag - if curl -s -o /dev/null -w "%{http_code}" "${repo_url}/tags/${version}" | grep -q "^200$"; then - return 0 - else - return 1 - fi - } # Check if the version is valid and exists - if [[ "$node_version" == "latest" || "$node_version" == "pre-release" || "$node_version" == "dev" || "$node_version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - if check_version_exists "$node_version"; then - colorized_echo cyan "================================" - colorized_echo cyan "Installing PasarGuard Node" - colorized_echo cyan "Version: $node_version" - colorized_echo cyan "================================" - if [ "$node_version" == "dev" ]; then - colorized_echo yellow "Note: pasarguard/node:dev is an unreleased build of the node dev branch, meant for testing only." - colorized_echo yellow " '$APP_NAME update' pulls the newest dev build." - fi - install_node "$node_version" - colorized_echo green "✓ Node installation completed for version: $node_version" - else - colorized_echo red "✗ Version $node_version does not exist. Please enter a valid version (e.g. v0.1.2)" - exit 1 - fi - else - colorized_echo red "✗ Invalid version format. Please enter a valid version (e.g. v1.0.0)" + if ! validate_node_version; then exit 1 fi + colorized_echo cyan "================================" + colorized_echo cyan "Installing PasarGuard Node" + colorized_echo cyan "Version: $node_version" + colorized_echo cyan "================================" + if [ "$node_version" == "dev" ]; then + print_dev_image_note + fi + install_node "$node_version" + colorized_echo green "✓ Node installation completed for version: $node_version" install_node_script install_completion up_node @@ -1739,6 +1761,9 @@ logs_command() { update_command() { check_running_as_root local no_update_service=false + # Image tag to switch to (--dev or --version); empty keeps the tag in the compose file + local node_version="" + local node_version_set="false" # Parse args while [[ "$#" -gt 0 ]]; do case "$1" in @@ -1746,6 +1771,28 @@ update_command() { no_update_service=true shift ;; + --dev) + if [[ "$node_version_set" == "true" ]]; then + colorized_echo red "Error: Cannot use --dev and --version options simultaneously." + exit 1 + fi + node_version="dev" + node_version_set="true" + shift + ;; + -v | --version) + if [[ "$node_version_set" == "true" ]]; then + colorized_echo red "Error: Cannot use --dev and --version options simultaneously." + exit 1 + fi + if [[ -z "${2:-}" ]]; then + colorized_echo red "Error: --version requires a value." + exit 1 + fi + node_version="$2" + node_version_set="true" + shift 2 + ;; *) break ;; @@ -1757,10 +1804,34 @@ update_command() { colorized_echo red "node not installed!" exit 1 fi + if [ "$node_version_set" = true ]; then + if ! command -v jq >/dev/null 2>&1; then + detect_os + install_package jq + fi + if ! command -v yq >/dev/null 2>&1; then + install_yq + fi + if ! validate_node_version; then + exit 1 + fi + fi detect_compose update_node_script uninstall_completion install_completion + if [ "$node_version_set" = true ]; then + # Only the image tag changes; .env, the API key and certificates stay as they are + colorized_echo blue "Switching node image to: $node_version" + if [ "$node_version" == "dev" ]; then + print_dev_image_note + fi + if ! set_node_image_tag "$node_version" "$COMPOSE_FILE"; then + colorized_echo red "✗ Failed to set the node image tag in $COMPOSE_FILE" + exit 1 + fi + colorized_echo green "✓ Docker image version set to: $node_version" + fi colorized_echo blue "Pulling latest version" update_node colorized_echo blue "Restarting node services" @@ -2137,6 +2208,8 @@ usage() { colorized_echo yellow " --no-restart-service $(tput sgr0)✓ Skip restarting systemd service" colorized_echo cyan "Update Options:" colorized_echo yellow " --no-update-service $(tput sgr0)✓ Skip updating systemd service" + colorized_echo yellow " --dev $(tput sgr0)✓ Switch to the dev image (unreleased, for testing)" + colorized_echo yellow " -v, --version VERSION $(tput sgr0)✓ Switch to a release tag, latest or pre-release" colorized_echo cyan "Uninstall Options:" colorized_echo yellow " -y, --yes $(tput sgr0)✓ Auto-confirm uninstall and data removal" colorized_echo yellow " --name NAME $(tput sgr0)✓ Uninstall specific node instance" diff --git a/tests/unit_pgnode.sh b/tests/unit_pgnode.sh index f102999..9cd6f5a 100644 --- a/tests/unit_pgnode.sh +++ b/tests/unit_pgnode.sh @@ -323,6 +323,17 @@ INSTALL_CALLS="$WORK_DIR/install-calls.log" INSTALL_OUT="" INSTALL_STATUS=0 +# Canned GitHub releases API answers for the curl stubs: v9.9.99 does not exist. +release_api_reply() { + case "$*" in + *"/tags/v9.9.99"*) printf '404' ;; + *"%{http_code}"*) printf '200' ;; + */releases/latest*) printf '{"tag_name":"v9.9.9"}' ;; + */releases) printf '[{"tag_name":"v9.9.10-beta","prerelease":true}]' ;; + *) printf '{}' ;; + esac +} + # Run install_command with stubbed host steps; sets INSTALL_OUT and INSTALL_STATUS. run_install_command() { : > "$INSTALL_CALLS" @@ -336,15 +347,7 @@ run_install_command() { ensure_docker_compose() { :; } install_yq() { :; } detect_compose() { :; } - curl() { - echo "curl $*" >> "$INSTALL_CALLS" - case "$*" in - *"%{http_code}"*) printf '200' ;; - */releases/latest*) printf '{"tag_name":"v9.9.9"}' ;; - */releases) printf '[{"tag_name":"v9.9.10-beta","prerelease":true}]' ;; - *) printf '{}' ;; - esac - } + curl() { echo "curl $*" >> "$INSTALL_CALLS"; release_api_reply "$@"; } install_node() { echo "install_node $1" >> "$INSTALL_CALLS"; } install_node_script() { :; } install_completion() { :; } @@ -453,40 +456,195 @@ else fi # ----------------------------------------------------------------------- -# update_command keeps whatever image tag the compose file has (dev stays dev) +# update_command image selection (--dev, --version) and the plain update. +# Host steps, compose and curl are stubbed in a subshell; the compose file, +# .env and certs are real files in a fresh temp dir per case. # ----------------------------------------------------------------------- -update_app_dir="$WORK_DIR/update-test" -mkdir -p "$update_app_dir" -cat > "$update_app_dir/docker-compose.yml" <<'EOF' +UPDATE_CALLS="$WORK_DIR/update-calls.log" +UPDATE_DIR="" +UPDATE_OUT="" +UPDATE_STATUS=0 +UPDATE_CASE=0 +UPDATE_TAG_FAILS="false" + +# Create an installed node layout whose compose image uses the given tag. +make_update_fixture() { + UPDATE_CASE=$((UPDATE_CASE + 1)) + UPDATE_DIR="$WORK_DIR/update-$UPDATE_CASE" + mkdir -p "$UPDATE_DIR/app" "$UPDATE_DIR/data/certs" "$UPDATE_DIR/before" + cat > "$UPDATE_DIR/app/docker-compose.yml" < "$update_calls" -if ( - APP_DIR="$update_app_dir" - COMPOSE_FILE="$update_app_dir/docker-compose.yml" - set_service_paths - check_running_as_root() { :; } - detect_compose() { COMPOSE="fake_compose"; } - fake_compose() { echo "compose $*" >> "$update_calls"; } - ensure_docker_service_running() { :; } - update_node_script() { :; } - uninstall_completion() { :; } - install_completion() { :; } - update_service_if_installed() { :; } - update_command --no-update-service >/dev/null 2>&1 -); then - pass "update on a dev install: exits 0" + printf 'SERVICE_PORT= 62050\nAPI_KEY= 11111111-2222-4333-8444-555555555555\nAPI_PORT= 62051\n' > "$UPDATE_DIR/app/.env" + printf 'CERT\n' > "$UPDATE_DIR/data/certs/ssl_cert.pem" + printf 'KEY\n' > "$UPDATE_DIR/data/certs/ssl_key.pem" + cp "$UPDATE_DIR/app/docker-compose.yml" "$UPDATE_DIR/app/.env" "$UPDATE_DIR/data/certs/ssl_cert.pem" \ + "$UPDATE_DIR/data/certs/ssl_key.pem" "$UPDATE_DIR/before/" +} + +# Run update_command against the current fixture; sets UPDATE_OUT and UPDATE_STATUS. +run_update_command() { + : > "$UPDATE_CALLS" + if UPDATE_OUT=$( + APP_DIR="$UPDATE_DIR/app" + DATA_DIR="$UPDATE_DIR/data" + COMPOSE_FILE="$APP_DIR/docker-compose.yml" + ENV_FILE="$APP_DIR/.env" + set_service_paths + check_running_as_root() { :; } + detect_os() { :; } + install_package() { :; } + install_yq() { :; } + detect_compose() { COMPOSE="fake_compose"; } + fake_compose() { echo "compose $*" >> "$UPDATE_CALLS"; } + ensure_docker_service_running() { :; } + update_node_script() { echo "update_node_script" >> "$UPDATE_CALLS"; } + uninstall_completion() { :; } + install_completion() { :; } + update_service_if_installed() { echo "update_service_if_installed" >> "$UPDATE_CALLS"; } + curl() { echo "curl $*" >> "$UPDATE_CALLS"; release_api_reply "$@"; } + if [ "$UPDATE_TAG_FAILS" = "true" ]; then + set_node_image_tag() { return 1; } + fi + update_command "$@" 2>&1 + ); then + UPDATE_STATUS=0 + else + UPDATE_STATUS=$? + fi +} + +# Print the image line of the fixture compose file. +fixture_image() { + sed -n 's/^ image: //p' "$UPDATE_DIR/app/docker-compose.yml" +} + +# Succeed if the given fixture file is byte-identical to its copy taken before the run. +fixture_unchanged() { + cmp -s "$UPDATE_DIR/before/$(basename "$1")" "$1" +} + +# Assert .env and both cert files are byte-identical after the run. +assert_secrets_unchanged() { + assert_true "$1: .env unchanged" fixture_unchanged "$UPDATE_DIR/app/.env" + assert_true "$1: ssl_cert.pem unchanged" fixture_unchanged "$UPDATE_DIR/data/certs/ssl_cert.pem" + assert_true "$1: ssl_key.pem unchanged" fixture_unchanged "$UPDATE_DIR/data/certs/ssl_key.pem" +} + +# Assert the run stopped before touching anything: compose file intact, no script update, no pull. +assert_update_refused() { + assert_eq "$UPDATE_STATUS" "1" "$1: exits 1" + assert_true "$1: compose file unchanged" fixture_unchanged "$UPDATE_DIR/app/docker-compose.yml" + assert_false "$1: no script update" grep -q "^update_node_script$" "$UPDATE_CALLS" + assert_false "$1: no image pull" grep -q " pull$" "$UPDATE_CALLS" +} + +# Plain update (what node-serviced runs) keeps the compose file as it is +make_update_fixture "latest" +run_update_command +assert_eq "$UPDATE_STATUS" "0" "update: exits 0" +assert_true "update: compose file byte-identical" fixture_unchanged "$UPDATE_DIR/app/docker-compose.yml" +assert_false "update: no release lookup" grep -q "^curl " "$UPDATE_CALLS" +assert_true "update: pulls images" grep -q " pull$" "$UPDATE_CALLS" +assert_true "update: updates node-serviced" grep -q "^update_service_if_installed$" "$UPDATE_CALLS" +assert_secrets_unchanged "update" + +make_update_fixture "dev" +run_update_command --no-update-service +assert_eq "$UPDATE_STATUS" "0" "update --no-update-service on dev: exits 0" +assert_true "update --no-update-service on dev: compose file byte-identical (dev stays dev)" \ + fixture_unchanged "$UPDATE_DIR/app/docker-compose.yml" +assert_true "update --no-update-service on dev: pulls images" grep -q " pull$" "$UPDATE_CALLS" +assert_false "update --no-update-service on dev: skips node-serviced" \ + grep -q "^update_service_if_installed$" "$UPDATE_CALLS" + +# --dev and --version are mutually exclusive; bad versions stop before any change +for update_conflict in "--dev --version v1.2.3" "--version v1.2.3 --dev" "--no-update-service -v v1.2.3 --dev" "--dev --dev"; do + make_update_fixture "latest" + # shellcheck disable=SC2086 + run_update_command $update_conflict + assert_update_refused "update $update_conflict" + if [[ "$UPDATE_OUT" == *"Cannot use --dev and --version options simultaneously."* ]]; then + pass "update $update_conflict: names both exclusive options" + else + fail "update $update_conflict: names both exclusive options (got: $UPDATE_OUT)" + fi +done + +make_update_fixture "latest" +run_update_command --version +assert_update_refused "update --version (no value)" + +make_update_fixture "latest" +run_update_command --version 1.2 +assert_update_refused "update --version 1.2" + +make_update_fixture "dev" +run_update_command --version v9.9.99 +assert_update_refused "update --version v9.9.99 (no such release)" +assert_true "update --version v9.9.99: checked releases/tags/v9.9.99" \ + grep -q "PasarGuard/node/releases/tags/v9.9.99" "$UPDATE_CALLS" + +make_update_fixture "latest" +UPDATE_TAG_FAILS="true" +run_update_command --dev +UPDATE_TAG_FAILS="false" +assert_eq "$UPDATE_STATUS" "1" "update --dev with a failing tag rewrite: exits 1" +assert_false "update --dev with a failing tag rewrite: no image pull" grep -q " pull$" "$UPDATE_CALLS" + +# The tag rewrites themselves need mikefarah yq v4; skipped otherwise. +if command -v yq >/dev/null 2>&1 && yq --version 2>/dev/null | grep -q "mikefarah"; then + make_update_fixture "latest" + run_update_command --dev + assert_eq "$UPDATE_STATUS" "0" "update --dev: exits 0" + assert_eq "$(fixture_image)" "pasarguard/node:dev" "update --dev: image becomes pasarguard/node:dev" + assert_false "update --dev: no release lookup" grep -q "^curl " "$UPDATE_CALLS" + assert_true "update --dev: pulls images" grep -q " pull$" "$UPDATE_CALLS" + if [[ "$UPDATE_OUT" == *"unreleased"* ]]; then + pass "update --dev: prints the unreleased build note" + else + fail "update --dev: prints the unreleased build note" + fi + assert_secrets_unchanged "update --dev" + + for dev_args in "--no-update-service --dev" "--dev --no-update-service"; do + make_update_fixture "latest" + # shellcheck disable=SC2086 + run_update_command $dev_args + assert_eq "$UPDATE_STATUS" "0" "update $dev_args: exits 0" + assert_eq "$(fixture_image)" "pasarguard/node:dev" "update $dev_args: image becomes pasarguard/node:dev" + assert_false "update $dev_args: skips node-serviced" grep -q "^update_service_if_installed$" "$UPDATE_CALLS" + done + + make_update_fixture "dev" + run_update_command --version v1.2.3 + assert_eq "$UPDATE_STATUS" "0" "update --version v1.2.3: exits 0" + assert_eq "$(fixture_image)" "pasarguard/node:v1.2.3" "update --version v1.2.3: image becomes pasarguard/node:v1.2.3" + assert_true "update --version v1.2.3: checked releases/tags/v1.2.3" \ + grep -q "PasarGuard/node/releases/tags/v1.2.3" "$UPDATE_CALLS" + assert_true "update --version v1.2.3: pulls images" grep -q " pull$" "$UPDATE_CALLS" + assert_secrets_unchanged "update --version v1.2.3" + + make_update_fixture "dev" + run_update_command -v latest --no-update-service + assert_eq "$UPDATE_STATUS" "0" "update -v latest: exits 0" + assert_eq "$(fixture_image)" "pasarguard/node:latest" "update -v latest: dev node goes back to pasarguard/node:latest" + assert_true "update -v latest: checked releases/latest" grep -q "PasarGuard/node/releases/latest" "$UPDATE_CALLS" + + make_update_fixture "latest" + run_update_command --version pre-release + assert_eq "$UPDATE_STATUS" "0" "update --version pre-release: exits 0" + assert_eq "$(fixture_image)" "pasarguard/node:v9.9.10-beta" \ + "update --version pre-release: image becomes the newest release tag" else - fail "update on a dev install: exits 0" + echo "(skipped update --dev/--version tag rewrites: mikefarah yq v4 not available)" fi -assert_true "update on a dev install: pulls images" grep -q " pull$" "$update_calls" -assert_true "update on a dev install: image stays pasarguard/node:dev" \ - grep -q "^ image: pasarguard/node:dev$" "$update_app_dir/docker-compose.yml" echo "" echo "Results: $PASS passed, $FAIL failed" From dd935f053db8e920c0c19211c4c30dab04cbb1ab Mon Sep 17 00:00:00 2001 From: T3ST3ST3R0N Date: Tue, 20 Oct 2026 07:45:00 +0330 Subject: [PATCH 3/4] fix(pg-node): refuse an empty resolved version and keep registry ports in the tag rewrite When api.github.com is unreachable or rate limited, the pre-release lookup printed nothing, so `pre-release` resolved to an empty tag and `update --version pre-release` (and `install --pre-release`) wrote `pasarguard/node:` into the compose file. Empty lookup results now count as "no release", and validate_node_version refuses any resolved tag that is not latest, dev or vX.Y.Z[-suffix] before the compose file is touched. `latest` needs no resolved tag and still works without the API, as before. set_node_image_tag now replaces only a tag after the last path segment, so registry.example.com:5000/pasarguard/node keeps its port and an untagged image gets a tag. yq errors are no longer discarded, and the update error says mikefarah yq v4 is required. Docs: `--version latest` sets the `latest` image tag, which follows stable releases; a node installed before these options needs one plain `pg-node update` first so the script learns them. --- README.md | 1 + docs/cli-reference.md | 6 +++-- pg-node.sh | 15 +++++++++-- tests/unit_pgnode.sh | 62 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eabb886..ed8165a 100644 --- a/README.md +++ b/README.md @@ -140,6 +140,7 @@ sudo bash pg-node.sh install --name node-de1 --self-signed # Testers only: the unreleased pasarguard/node:dev image (built from the node dev branch) sudo bash pg-node.sh install --dev # or switch an installed node to it, and back to the latest release later +# (a node installed before these options existed: run `sudo pg-node update` once first) sudo pg-node update --dev sudo pg-node update --version latest ``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f33d379..fd12765 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -245,10 +245,12 @@ Updates the `pg-node` script, pulls the node image and recreates the container. **Options:** - `--dev`: Switch the node to the `pasarguard/node:dev` image, built from the node `dev` branch (unreleased, for testing). -- `-v`, `--version `: Switch the node to a release tag (`vX.Y.Z`), `latest` (the latest stable release) or `pre-release`. Checked against GitHub releases the same way as `install --version`. +- `-v`, `--version `: Switch the node to a release tag (`vX.Y.Z`), `latest` (the `latest` image tag, which follows stable releases on later updates) or `pre-release` (pins the newest release tag, pre-releases included). Checked against GitHub releases the same way as `install --version`; if that lookup fails, `pre-release` is refused before anything changes. - `--no-update-service`: Do not update the companion node-serviced daemon. -`--dev` and `--version` cannot be combined. +`--dev` and `--version` cannot be combined. The line `Switching node image to: dev` (or the chosen tag) shows that the switch was applied. + +On a node installed before these options existed, the installed script ignores them on its first run. Run `sudo pg-node update` once (it also updates the script itself), then `sudo pg-node update --dev`. ```bash sudo pg-node update --dev diff --git a/pg-node.sh b/pg-node.sh index e6329ac..be92f48 100755 --- a/pg-node.sh +++ b/pg-node.sh @@ -712,13 +712,15 @@ read_and_save_file() { } # Replace the image tag of the node service in a compose file (e.g. :latest -> :dev). +# Only a tag after the last path segment is replaced (an untagged image gets one), +# so a registry port such as registry:5000/pasarguard/node is kept. Needs mikefarah yq v4. # Arguments: # $1 - Image tag (a release tag such as v0.5.4, or dev). # $2 - Path to the docker-compose.yml to edit in place. set_node_image_tag() { local tag="$1" local compose_file="$2" - yq eval ".services[\"node\"].image = (.services[\"node\"].image | sub(\":.*$\"; \":${tag}\"))" -i "$compose_file" 2>/dev/null + yq eval ".services[\"node\"].image = (.services[\"node\"].image | sub(\"(:[^/:]*)?$\"; \":${tag}\"))" -i "$compose_file" } # Download compose files, set up certificates, configure .env, and deploy the node. @@ -1098,6 +1100,9 @@ check_node_version_exists() { if [ "$version" == "pre-release" ]; then local latest_stable_tag=$(curl -s "$repo_url/latest" | jq -r '.tag_name') local latest_pre_release_tag=$(curl -s "$repo_url" | jq -r '[.[] | select(.prerelease == true)][0].tag_name') + # An unreachable or rate-limited API leaves these empty; treat that like "no release" + [ -n "$latest_stable_tag" ] || latest_stable_tag="null" + [ -n "$latest_pre_release_tag" ] || latest_pre_release_tag="null" if [ "$latest_stable_tag" == "null" ] && [ "$latest_pre_release_tag" == "null" ]; then return 1 # No releases found at all elif [ "$latest_stable_tag" == "null" ]; then @@ -1133,6 +1138,12 @@ validate_node_version() { colorized_echo red "✗ Version $node_version does not exist. Please enter a valid version (e.g. v0.1.2)" return 1 fi + # Never hand an empty or malformed resolved tag to the compose rewrite + local release_tag_regex='^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$' + if ! [[ "$node_version" == "latest" || "$node_version" == "dev" || "$node_version" =~ $release_tag_regex ]]; then + colorized_echo red "✗ Could not resolve a node release tag (got '$node_version'). Check that api.github.com is reachable." + return 1 + fi return 0 } @@ -1827,7 +1838,7 @@ update_command() { print_dev_image_note fi if ! set_node_image_tag "$node_version" "$COMPOSE_FILE"; then - colorized_echo red "✗ Failed to set the node image tag in $COMPOSE_FILE" + colorized_echo red "✗ Failed to set the node image tag in $COMPOSE_FILE (mikefarah yq v4 is required)" exit 1 fi colorized_echo green "✓ Docker image version set to: $node_version" diff --git a/tests/unit_pgnode.sh b/tests/unit_pgnode.sh index 9cd6f5a..47b9c90 100644 --- a/tests/unit_pgnode.sh +++ b/tests/unit_pgnode.sh @@ -324,7 +324,20 @@ INSTALL_OUT="" INSTALL_STATUS=0 # Canned GitHub releases API answers for the curl stubs: v9.9.99 does not exist. +# RELEASE_API_MODE=empty simulates an unreachable api.github.com (curl prints +# nothing); RELEASE_API_MODE=ratelimit returns GitHub's rate-limit reply. +RELEASE_API_MODE="normal" release_api_reply() { + case "$RELEASE_API_MODE" in + empty) return 0 ;; + ratelimit) + case "$*" in + *"%{http_code}"*) printf '403' ;; + *) printf '{"message":"API rate limit exceeded"}' ;; + esac + return 0 + ;; + esac case "$*" in *"/tags/v9.9.99"*) printf '404' ;; *"%{http_code}"*) printf '200' ;; @@ -425,6 +438,22 @@ run_install_command --version 1.2 assert_eq "$INSTALL_STATUS" "1" "install --version 1.2: rejects an invalid version" assert_eq "$(installed_version)" "" "install --version 1.2: installs nothing" +# A failed release lookup must never resolve pre-release to an empty tag +for api_mode in empty ratelimit; do + RELEASE_API_MODE="$api_mode" + run_install_command --pre-release + RELEASE_API_MODE="normal" + assert_eq "$INSTALL_STATUS" "1" "install --pre-release, GitHub $api_mode: exits 1" + assert_false "install --pre-release, GitHub $api_mode: install_node not called" grep -q "^install_node" "$INSTALL_CALLS" +done + +# The default install keeps working without api.github.com: latest needs no resolved tag +RELEASE_API_MODE="empty" +run_install_command +RELEASE_API_MODE="normal" +assert_eq "$INSTALL_STATUS" "0" "install (latest), GitHub unreachable: exits 0" +assert_eq "$(installed_version)" "latest" "install (latest), GitHub unreachable: installs image tag latest" + # ----------------------------------------------------------------------- # set_node_image_tag (compose image rewrite used by install_node) # Needs mikefarah yq v4 (what install_yq installs); skipped otherwise. @@ -451,6 +480,23 @@ if command -v yq >/dev/null 2>&1 && yq --version 2>/dev/null | grep -q "mikefara fi assert_eq "$(yq eval -r '.services["node"].image' "$image_compose")" "pasarguard/node:v0.5.4" \ "set_node_image_tag: a dev compose file can move back to a release tag" + + # Only the tag after the last path segment changes; untagged images get one + for image_case in "pasarguard/node|pasarguard/node:dev" \ + "registry.example.com:5000/pasarguard/node:latest|registry.example.com:5000/pasarguard/node:dev" \ + "registry.example.com:5000/pasarguard/node|registry.example.com:5000/pasarguard/node:dev" \ + "mirror.example.com/pasarguard/node:v0.5.4|mirror.example.com/pasarguard/node:dev"; do + image_before="${image_case%%|*}" + image_after="${image_case#*|}" + yq eval ".services[\"node\"].image = \"$image_before\"" -i "$image_compose" + if set_node_image_tag "dev" "$image_compose"; then + pass "set_node_image_tag: $image_before rewrite succeeds" + else + fail "set_node_image_tag: $image_before rewrite succeeds" + fi + assert_eq "$(yq eval -r '.services["node"].image' "$image_compose")" "$image_after" \ + "set_node_image_tag: $image_before -> $image_after" + done else echo "(skipped set_node_image_tag: mikefarah yq v4 not available)" fi @@ -591,6 +637,14 @@ assert_update_refused "update --version v9.9.99 (no such release)" assert_true "update --version v9.9.99: checked releases/tags/v9.9.99" \ grep -q "PasarGuard/node/releases/tags/v9.9.99" "$UPDATE_CALLS" +for api_mode in empty ratelimit; do + make_update_fixture "latest" + RELEASE_API_MODE="$api_mode" + run_update_command --version pre-release + RELEASE_API_MODE="normal" + assert_update_refused "update --version pre-release, GitHub $api_mode" +done + make_update_fixture "latest" UPDATE_TAG_FAILS="true" run_update_command --dev @@ -637,6 +691,14 @@ if command -v yq >/dev/null 2>&1 && yq --version 2>/dev/null | grep -q "mikefara assert_eq "$(fixture_image)" "pasarguard/node:latest" "update -v latest: dev node goes back to pasarguard/node:latest" assert_true "update -v latest: checked releases/latest" grep -q "PasarGuard/node/releases/latest" "$UPDATE_CALLS" + make_update_fixture "dev" + RELEASE_API_MODE="empty" + run_update_command --version latest + RELEASE_API_MODE="normal" + assert_eq "$UPDATE_STATUS" "0" "update --version latest, GitHub unreachable: exits 0" + assert_eq "$(fixture_image)" "pasarguard/node:latest" \ + "update --version latest, GitHub unreachable: image becomes pasarguard/node:latest" + make_update_fixture "latest" run_update_command --version pre-release assert_eq "$UPDATE_STATUS" "0" "update --version pre-release: exits 0" From 9649dc0271773959a6c2310151f94978d07256ac Mon Sep 17 00:00:00 2001 From: T3ST3ST3R0N Date: Tue, 20 Oct 2026 08:00:53 +0330 Subject: [PATCH 4/4] fix(pg-node): prefer the stable release over its own release candidate sort -V orders v1.0.0-rc.1 after v1.0.0, so --pre-release (install and update) could pick an older release candidate over the stable release it led to. Compare the base versions instead, so the result does not depend on whether GNU or BusyBox sort is installed. A fresh install that cannot set a requested non-default image tag now stops with an error naming the tag and mikefarah yq v4, as update does, instead of finishing on the latest image. --- pg-node.sh | 44 ++++++++++++++++++++++++++--------- tests/unit_pgnode.sh | 55 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/pg-node.sh b/pg-node.sh index be92f48..dcbe342 100755 --- a/pg-node.sh +++ b/pg-node.sh @@ -723,6 +723,25 @@ set_node_image_tag() { yq eval ".services[\"node\"].image = (.services[\"node\"].image | sub(\"(:[^/:]*)?$\"; \":${tag}\"))" -i "$compose_file" } +# Point a fresh install's compose file at the requested image tag. latest is the +# downloaded default, so nothing is rewritten; any other tag must be set, or the +# install would finish on the wrong image, so a failed rewrite stops the install. +# Arguments: +# $1 - Requested node version (latest, dev or a release tag). +# $2 - Path to the docker-compose.yml to edit in place. +set_install_image_tag() { + local node_version="$1" + local compose_file="$2" + [ "$node_version" = "latest" ] && return 0 + colorized_echo cyan " Command: yq eval ...image = ...:${node_version}..." + if set_node_image_tag "$node_version" "$compose_file"; then + colorized_echo green " ✓ Docker image version set to: ${node_version}" + else + colorized_echo red " ✗ Failed to set the node image tag to ${node_version} (mikefarah yq v4 is required)" + exit 1 + fi +} + # Download compose files, set up certificates, configure .env, and deploy the node. install_node() { local node_version=$1 @@ -897,14 +916,7 @@ install_node() { ssl_key_env="${container_path}/certs/ssl_key.pem" sed -i "s|^SSL_CERT_FILE *=.*|SSL_CERT_FILE= ${ssl_cert_env}|" "$APP_DIR/.env" sed -i "s|^SSL_KEY_FILE *=.*|SSL_KEY_FILE= ${ssl_key_env}|" "$APP_DIR/.env" - if [ "$node_version" != "latest" ]; then - colorized_echo cyan " Command: yq eval ...image = ...:${node_version}..." - if set_node_image_tag "$node_version" "$APP_DIR/docker-compose.yml"; then - colorized_echo green " ✓ Docker image version set to: ${node_version}" - else - colorized_echo yellow " ⚠ Failed to set image version (may not be critical)" - fi - fi + set_install_image_tag "$node_version" "$APP_DIR/docker-compose.yml" # Final sync to ensure env has the correct SSL paths for custom names sync_env_ssl_paths colorized_echo green "✓ docker-compose.yml modified successfully" @@ -1110,9 +1122,19 @@ check_node_version_exists() { elif [ "$latest_pre_release_tag" == "null" ]; then node_version=$latest_stable_tag else - # Compare versions using sort -V - local chosen_version=$(printf "%s\n" "$latest_stable_tag" "$latest_pre_release_tag" | sort -V | tail -n 1) - node_version=$chosen_version + # sort -V puts v1.0.0-rc.1 after v1.0.0, which would pick an older release + # candidate over the stable release it led to. Compare the base versions + # (the part before the first "-") instead: a pre-release wins only when its + # base is newer, so the result does not depend on which sort -V is installed. + local stable_base="${latest_stable_tag%%-*}" + local pre_release_base="${latest_pre_release_tag%%-*}" + local newest_base + newest_base=$(printf "%s\n" "$stable_base" "$pre_release_base" | sort -V | tail -n 1) + if [ "$pre_release_base" != "$stable_base" ] && [ "$newest_base" = "$pre_release_base" ]; then + node_version=$latest_pre_release_tag + else + node_version=$latest_stable_tag + fi fi return 0 fi diff --git a/tests/unit_pgnode.sh b/tests/unit_pgnode.sh index 47b9c90..83ed4b9 100644 --- a/tests/unit_pgnode.sh +++ b/tests/unit_pgnode.sh @@ -337,6 +337,16 @@ release_api_reply() { esac return 0 ;; + rc_of_stable) + # The newest pre-release is an older candidate of the current stable release + case "$*" in + *"%{http_code}"*) printf '200' ;; + */releases/latest*) printf '{"tag_name":"v9.9.9"}' ;; + */releases) printf '[{"tag_name":"v9.9.9-rc.1","prerelease":true}]' ;; + *) printf '{}' ;; + esac + return 0 + ;; esac case "$*" in *"/tags/v9.9.99"*) printf '404' ;; @@ -426,6 +436,12 @@ run_install_command --pre-release assert_eq "$INSTALL_STATUS" "0" "install --pre-release: exits 0" assert_eq "$(installed_version)" "v9.9.10-beta" "install --pre-release: installs the newest release tag" +RELEASE_API_MODE="rc_of_stable" +run_install_command --pre-release +RELEASE_API_MODE="normal" +assert_eq "$INSTALL_STATUS" "0" "install --pre-release, rc older than stable: exits 0" +assert_eq "$(installed_version)" "v9.9.9" "install --pre-release, rc older than stable: installs the stable release" + run_install_command --version v1.2.3 assert_eq "$INSTALL_STATUS" "0" "install --version v1.2.3: exits 0" assert_eq "$(installed_version)" "v1.2.3" "install --version v1.2.3: installs image tag v1.2.3" @@ -501,6 +517,37 @@ else echo "(skipped set_node_image_tag: mikefarah yq v4 not available)" fi +# ----------------------------------------------------------------------- +# set_install_image_tag: install must stop when it cannot set the requested tag +# ----------------------------------------------------------------------- +INSTALL_TAG_CALLS="$WORK_DIR/install-tag-calls.log" +for tag_case in "dev fail" "v1.2.3 fail" "dev ok" "latest fail"; do + read -r tag_version tag_result <<<"$tag_case" + : > "$INSTALL_TAG_CALLS" + if tag_out=$( + set_node_image_tag() { echo "set_node_image_tag $1" >> "$INSTALL_TAG_CALLS"; [ "$tag_result" = "ok" ]; } + set_install_image_tag "$tag_version" "$WORK_DIR/unused-compose.yml" 2>&1 + ); then + tag_status=0 + else + tag_status=$? + fi + case "$tag_case" in + "latest fail") + assert_eq "$tag_status" "0" "set_install_image_tag latest: exits 0" + assert_false "set_install_image_tag latest: no rewrite" test -s "$INSTALL_TAG_CALLS" + ;; + *fail) + assert_eq "$tag_status" "1" "set_install_image_tag $tag_version, rewrite fails: exits 1" + assert_true "set_install_image_tag $tag_version, rewrite fails: names the tag and mikefarah yq v4" \ + grep -q "${tag_version}.*mikefarah yq v4" <<<"$tag_out" + ;; + *ok) + assert_eq "$tag_status" "0" "set_install_image_tag $tag_version, rewrite ok: exits 0" + ;; + esac +done + # ----------------------------------------------------------------------- # update_command image selection (--dev, --version) and the plain update. # Host steps, compose and curl are stubbed in a subshell; the compose file, @@ -704,6 +751,14 @@ if command -v yq >/dev/null 2>&1 && yq --version 2>/dev/null | grep -q "mikefara assert_eq "$UPDATE_STATUS" "0" "update --version pre-release: exits 0" assert_eq "$(fixture_image)" "pasarguard/node:v9.9.10-beta" \ "update --version pre-release: image becomes the newest release tag" + + make_update_fixture "latest" + RELEASE_API_MODE="rc_of_stable" + run_update_command --version pre-release + RELEASE_API_MODE="normal" + assert_eq "$UPDATE_STATUS" "0" "update --version pre-release, rc older than stable: exits 0" + assert_eq "$(fixture_image)" "pasarguard/node:v9.9.9" \ + "update --version pre-release, rc older than stable: image becomes the stable release" else echo "(skipped update --dev/--version tag rewrites: mikefarah yq v4 not available)" fi