From 24a1b694fa6c831b8e39d5efeade15cd08858352 Mon Sep 17 00:00:00 2001 From: MickLesk <47820557+MickLesk@users.noreply.github.com> Date: Wed, 11 Nov 2026 20:53:52 +0100 Subject: [PATCH] Deploy a release by moving it, and stop on a full disk Staging moved from /tmp to disk in #218, and the deploy step still copied the unpacked release into its target, so it was on disk twice. LibreNMS on its 4 GB disk ran out of space in 41 installs within four days. The tarball mode ignored cp's failure and returned 0, so the install went on with a partial tree and only failed when it could not write the version file. Entries the target does not have yet are now moved, the rest copied over and removed from staging. A failed deploy on a full disk says so and returns 252. --- lib/API.txt | 2 ++ lib/forge.func | 39 ++++++++++++++++++++++++++++++------ tests/forge-clean-install.sh | 35 +++++++++++++++++++++++++++++++- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/lib/API.txt b/lib/API.txt index 2d3fe27..1181a97 100644 --- a/lib/API.txt +++ b/lib/API.txt @@ -18,12 +18,14 @@ _forge_asset_urls _forge_curl _forge_descriptor _forge_designated_release +_forge_disk_full _forge_explain_failure _forge_highest_version _forge_http_get _forge_is_current _forge_is_script_app _forge_keep_raw +_forge_place _forge_previous_version _forge_release_json _forge_require_jq diff --git a/lib/forge.func b/lib/forge.func index 291567c..5cb0995 100644 --- a/lib/forge.func +++ b/lib/forge.func @@ -569,6 +569,33 @@ _forge_workdir() { mktemp -d -p "${TMPDIR:-/var/tmp}" 2>/dev/null || mktemp -d -p /tmp } +# Puts the entries of into : moved where has no +# such entry yet, copied over it otherwise. Staging is on disk, so a plain +# cp -r had the release there twice and filled small containers. +_forge_place() { + local src="$1" target="$2" entry rc=0 + shopt -s dotglob nullglob + for entry in "$src"/*; do + if [[ -e "$target/${entry##*/}" || -L "$target/${entry##*/}" ]]; then + cp -r "$entry" "$target/" && rm -rf "$entry" || rc=1 + else + mv "$entry" "$target/" || rc=1 + fi + done + shopt -u dotglob nullglob + return $rc +} + +# After a failed _forge_place: says so and returns 1 when 's filesystem +# is full, returns 0 for any other reason. +_forge_disk_full() { + local avail + avail="$(df -Pk "$1" 2>/dev/null | awk 'NR==2 {print $4}')" + [[ "$avail" =~ ^[0-9]+$ ]] && ((avail < 1024)) || return 0 + msg_error "No space left on the disk while deploying to $1 - give the container a larger disk" + return 1 +} + # ------------------------------------------------------------------------------ # _deploy_source_tarball # @@ -585,9 +612,10 @@ _forge_workdir() { # that the wipe leaves alone (e.g. "geodata .env data/uploads"). # - Does NOT own : the caller creates it and is responsible for its # cleanup (typically via a RETURN trap on its tmpdir). -# - cp failures are non-fatal here, matching the previous inline behavior. +# - A failed copy is fatal only when the disk is full; otherwise it stays +# non-fatal, matching the previous inline behavior. # -# Returns: 0 on success (or non-fatal cp failure), 251 on extraction failure. +# Returns: 0 on success, 251 on extraction failure, 252 when the disk is full. # ------------------------------------------------------------------------------ _deploy_source_tarball() { local tarball="$1" target="$2" workdir="$3" @@ -610,9 +638,7 @@ _deploy_source_tarball() { _cs_clean_target_dir "$target" fi - shopt -s dotglob nullglob - cp -r "$unpack_dir"/* "$target/" - shopt -u dotglob nullglob + _forge_place "$unpack_dir" "$target" || _forge_disk_full "$target" || return 252 return 0 } @@ -715,7 +741,8 @@ _deploy_unpacked_archive() { _cs_clean_target_dir "$target" fi - if ! cp -r "$source_dir"/* "$target/"; then + if ! _forge_place "$source_dir" "$target"; then + _forge_disk_full "$target" || true if [[ -n "$inner_dir" ]]; then msg_error "Failed to copy contents from $inner_dir to $target" else diff --git a/tests/forge-clean-install.sh b/tests/forge-clean-install.sh index 4d3d4a5..8401974 100644 --- a/tests/forge-clean-install.sh +++ b/tests/forge-clean-install.sh @@ -2,7 +2,7 @@ set -euo pipefail CORE_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" -eval "$(awk '/^(_cs_clean_target_dir|_deploy_source_tarball|_deploy_unpacked_archive|_forge_workdir)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" +eval "$(awk '/^(_cs_clean_target_dir|_deploy_source_tarball|_deploy_unpacked_archive|_forge_workdir|_forge_place|_forge_disk_full)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func")" TEST_DIR="$(mktemp -d)" trap 'rm -rf -- "$TEST_DIR"' EXIT @@ -92,5 +92,38 @@ check "workdir: defaults to /var/tmp, not /tmp" "/var/tmp" "${default_workdir%/* rm -rf -- "$default_workdir" check "no helper stages in the mktemp default" "0" "$(awk '/^(fetch_and_deploy_[a-z_]+|_forge_[a-z_]+)\(\) \{/,/^}/' "$CORE_DIR/lib/forge.func" | grep -c '_tmp="\{0,1\}$(mktemp -d)\|tmpdir="\{0,1\}$(mktemp -d)')" +# _forge_place: nothing of the release stays behind in staging, and what the +# target already has is merged, not replaced. +rm -rf "$TEST_DIR/src" "$TEST_DIR/dst" +mkdir -p "$TEST_DIR/src/lib" "$TEST_DIR/src/data" "$TEST_DIR/dst/data" +echo new >"$TEST_DIR/src/app" +echo new >"$TEST_DIR/src/lib/a" +echo new >"$TEST_DIR/src/data/schema" +echo hidden >"$TEST_DIR/src/.env.example" +echo mine >"$TEST_DIR/dst/data/db" +rc=0 +_forge_place "$TEST_DIR/src" "$TEST_DIR/dst" || rc=$? +check "place: succeeds" 0 "$rc" +check "place: staging is empty afterwards" "" "$(ls -A "$TEST_DIR/src")" +check "place: new entries arrive, dotfiles included" "new new hidden" "$(cat "$TEST_DIR/dst/app" "$TEST_DIR/dst/lib/a" "$TEST_DIR/dst/.env.example" | xargs)" +check "place: an existing directory is merged" "mine new" "$(cat "$TEST_DIR/dst/data/db" "$TEST_DIR/dst/data/schema" | xargs)" + +df() { printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\n/dev/x 4000000 4000000 %s 100%% /\n' "${FAKE_AVAIL:-0}"; } +rc=0 +FAKE_AVAIL=12 _forge_disk_full "$TEST_DIR/dst" || rc=$? +check "a full disk is reported" 1 "$rc" +rc=0 +FAKE_AVAIL=900000 _forge_disk_full "$TEST_DIR/dst" || rc=$? +check "a failure with space left is not blamed on the disk" 0 "$rc" + +# The tarball mode used to return 0 whatever cp did. +setup +mkdir -p "$TEST_DIR/pkg/app-1.0" && echo v1 >"$TEST_DIR/pkg/app-1.0/app" +tar -czf "$TEST_DIR/work/app.tar.gz" -C "$TEST_DIR/pkg" app-1.0 +_forge_place() { return 1; } +rc=0 +FAKE_AVAIL=0 _deploy_source_tarball "$TEST_DIR/work/app.tar.gz" "$TEST_DIR/target" "$TEST_DIR/work" || rc=$? +check "tarball: a full disk ends the deploy" 252 "$rc" + echo "${passed} passed, ${failed} failed" ((failed == 0))