From cc0c1be7fb9587978e34f9c7aaa1d7e8f74dcab5 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 5 Jun 2026 23:08:29 +0300 Subject: Add rsync sync command --- README.md | 33 +++++++-- bin/photoalbum | 113 ++++++++++++++++++++++++++++- src/photoalbum.default.conf | 9 +++ src/photoalbum.sh | 113 ++++++++++++++++++++++++++++- tests/cli.sh | 169 ++++++++++++++++++++++++++++++++++++++++++++ tests/helpers.sh | 21 ++++++ 6 files changed, 451 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5df5d71..52b0bd4 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ modern `magick` command and falls back to `convert` when needed. photoalbum --init photoalbum --generate [--config PATH] [OPTIONS] photoalbum --refresh-splash [--config PATH] [OPTIONS] +photoalbum --sync [--config PATH] [OPTIONS] photoalbum --dry-run [--config PATH] [OPTIONS] photoalbum --print-config [--config PATH] [OPTIONS] photoalbum --clean [--config PATH] [OPTIONS] @@ -51,6 +52,8 @@ photoalbum --version default config. It refuses to overwrite an existing file. * `--generate` builds the static album. * `--refresh-splash` rewrites only the generated root splash page. +* `--sync` publishes the generated output directory to configured rsync + destinations. * `--dry-run` loads the config and overrides, validates the planned generation, and prints the effective paths, image count, tarball plan, and generated file plan without writing output or running ImageMagick or tar. @@ -69,7 +72,7 @@ If the file is missing, run `photoalbum --init` first. The config file is a Bash file with assignments such as `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`, `TITLE`, `HEIGHT`, `THUMBHEIGHT`, `MAXPREVIEWS`, `IMAGE_JOBS`, `IMAGEMAGICK_TIMEOUT`, `RANDOM_SEED`, `SHUFFLE`, `SPLASH_PAGE`, -`TARBALL_INCLUDE`, and `TAR_TIMEOUT`. +`TARBALL_INCLUDE`, `TAR_TIMEOUT`, `SYNC_DELETE`, and `SYNC_DESTINATIONS`. Before generating, `photoalbum` validates the loaded config and command-line overrides. It checks required values, positive integer settings, `yes`/`no` @@ -90,9 +93,10 @@ tarball filename uses `` as a placeholder so the output is stable. `CONFIG_SOURCE`, `INCOMING_DIR`, `DIST_DIR`, `TEMPLATE_DIR`, `TITLE`, `HEIGHT`, `THUMBHEIGHT`, `MAXPREVIEWS`, `IMAGE_JOBS`, `IMAGEMAGICK_TIMEOUT`, `RANDOM_SEED`, `SHUFFLE`, `SPLASH_PAGE`, `TARBALL_INCLUDE`, -`TARBALL_SUFFIX`, `TAR_TIMEOUT`, `TAR_OPTS`, and `ORIGINAL_BASEPATH`. -Scalar values use Bash `%q` quoting and `TAR_OPTS` is normalized to a Bash array -assignment, so the output can be parsed by shell tooling. `--quiet` does not +`TARBALL_SUFFIX`, `TAR_TIMEOUT`, `TAR_OPTS`, `SYNC_DELETE`, +`SYNC_DESTINATIONS`, and `ORIGINAL_BASEPATH`. Scalar values use Bash `%q` +quoting and `TAR_OPTS` and `SYNC_DESTINATIONS` are normalized to Bash array +assignments, so the output can be parsed by shell tooling. `--quiet` does not suppress this output, and `--verbose` does not add human-readable diagnostics to it. @@ -120,6 +124,11 @@ The following long options override config values: | `--no-splash` | `SPLASH_PAGE=no` | | `--tarball` | `TARBALL_INCLUDE=yes` | | `--no-tarball` | `TARBALL_INCLUDE=no` | +| `--sync-delete` | `SYNC_DELETE=yes` | +| `--no-sync-delete` | `SYNC_DELETE=no` | + +Pass `--sync-destination DEST` one or more times with `--sync` to override the +configured sync destinations for that run. By default, the generated root `index.html` is a no-JavaScript splash page using a randomly selected album photo. Set `SPLASH_PAGE=no` or pass `--no-splash` to @@ -137,6 +146,20 @@ timestamps, and `--shuffle` preview order remain non-deterministic. Set choices repeatable for stable tests or reproducible album builds. Use the same seed and inputs to produce the same HTML. +To publish generated output, configure destinations and run `photoalbum --sync`: + +``` +SYNC_DESTINATIONS=( + admin@fishfinger.buetow.org:/var/www/htdocs/example.org/ + admin@blowfish.buetow.org:/var/www/htdocs/example.org/ +) +``` + +`--sync` runs `rsync -av --delete "$DIST_DIR/" "$destination"` for each +destination by default. The trailing slash on `DIST_DIR/` means the generated +contents are copied into the target directory. Set `SYNC_DELETE=no` or pass +`--no-sync-delete` to omit `--delete`. + `--dry-run`, `--print-config`, and `--refresh-splash` accept the same override options as `--generate`. `--clean` accepts the same override options, but only `--dist` changes what it removes. @@ -163,7 +186,7 @@ value to a positive integer to adjust the limit for large images or archives. pictures and adjust `DIST_DIR`, `TITLE`, or template settings as needed. 3. Run `photoalbum --dry-run` to inspect the planned generation. 4. Run `photoalbum --generate` to generate the album. -5. Distribute the `./dist` directory to a static web server. +5. Run `photoalbum --sync` to publish `./dist`, or distribute it manually. 6. Run `photoalbum --clean` to remove the generated output. ## HTML templates diff --git a/bin/photoalbum b/bin/photoalbum index d872179..6566c69 100755 --- a/bin/photoalbum +++ b/bin/photoalbum @@ -23,6 +23,7 @@ declare -ra CLI_CONFIG_OVERRIDE_TARGETS=( RANDOM_SEED SHUFFLE SPLASH_PAGE + SYNC_DELETE TARBALL_INCLUDE ) declare -Ar CLI_OPTION_KIND=( @@ -42,6 +43,9 @@ declare -Ar CLI_OPTION_KIND=( [--no-splash]=flag [--tarball]=flag [--no-tarball]=flag + [--sync-delete]=flag + [--no-sync-delete]=flag + [--sync-destination]=value [--verbose]=output [--quiet]=output [--version]=action @@ -49,6 +53,7 @@ declare -Ar CLI_OPTION_KIND=( [--clean]=action [--generate]=action [--refresh-splash]=action + [--sync]=action [--dry-run]=action [--print-config]=action ) @@ -64,6 +69,8 @@ declare -Ar CLI_OPTION_VALUE=( [--no-splash]=no [--tarball]=yes [--no-tarball]=no + [--sync-delete]=yes + [--no-sync-delete]=no [--verbose]=verbose [--quiet]=quiet ) @@ -83,9 +90,12 @@ declare -Ar CLI_OPTION_CONFIG_TARGET=( [--no-splash]=SPLASH_PAGE [--tarball]=TARBALL_INCLUDE [--no-tarball]=TARBALL_INCLUDE + [--sync-delete]=SYNC_DELETE + [--no-sync-delete]=SYNC_DELETE ) declare -Ar CLI_OPTION_ARGUMENT=( [--config]=path + [--sync-destination]=destination ) usage() { @@ -93,6 +103,7 @@ usage() { Usage: $0 --generate [--config PATH] [OPTIONS] $0 --refresh-splash [--config PATH] [OPTIONS] + $0 --sync [--config PATH] [OPTIONS] $0 --dry-run [--config PATH] [OPTIONS] $0 --print-config [--config PATH] [OPTIONS] $0 --clean [--config PATH] [OPTIONS] @@ -116,6 +127,9 @@ usage() { --no-shuffle --tarball --no-tarball + --sync-destination DEST + --sync-delete + --no-sync-delete --verbose --quiet USAGE @@ -407,6 +421,29 @@ resolve_tar_opts() { fi } +resolve_sync_destinations() { + local -n destinations_ref="$1"; shift + local destinations_decl + + destinations_ref=() + + if ! destinations_decl=$(declare -p SYNC_DESTINATIONS 2>/dev/null); then + return + fi + + case "$destinations_decl" in + declare\ -a*\ SYNC_DESTINATIONS=*) + destinations_ref=("${SYNC_DESTINATIONS[@]}") + ;; + *) + if [ -n "${SYNC_DESTINATIONS:-}" ]; then + # shellcheck disable=SC2034 + read -r -a destinations_ref <<< "$SYNC_DESTINATIONS" + fi + ;; + esac +} + _html_escape() { local text="$1"; shift @@ -2040,8 +2077,10 @@ print_shell_array_assignment() { print_config() { local -a tar_opts=() + local -a sync_destinations=() resolve_tar_opts tar_opts + resolve_sync_destinations sync_destinations print_shell_assignment CONFIG_SOURCE "${PHOTOALBUM_CONFIG_SOURCE:-}" print_shell_assignment INCOMING_DIR "$INCOMING_DIR" @@ -2060,9 +2099,28 @@ print_config() { print_shell_assignment TARBALL_SUFFIX "${TARBALL_SUFFIX:-.tar}" print_shell_assignment TAR_TIMEOUT "$TAR_TIMEOUT" print_shell_array_assignment TAR_OPTS "${tar_opts[@]}" + print_shell_assignment SYNC_DELETE "${SYNC_DELETE:-yes}" + print_shell_array_assignment SYNC_DESTINATIONS "${sync_destinations[@]}" print_shell_assignment ORIGINAL_BASEPATH "${ORIGINAL_BASEPATH:-}" } +sync_dist() { + local destination + local -a rsync_args=(-av) + local -a sync_destinations=() + + resolve_sync_destinations sync_destinations + + if [ "${SYNC_DELETE:-yes}" = yes ]; then + rsync_args+=(--delete) + fi + + for destination in "${sync_destinations[@]}"; do + log_info "Syncing $DIST_DIR/ to $destination" + rsync "${rsync_args[@]}" "$DIST_DIR/" "$destination" + done +} + existing_parent_dir() { local -r path="$1"; shift local existing_parent @@ -2256,12 +2314,16 @@ apply_config_defaults() { RANDOM_SEED="${RANDOM_SEED:-}" SHUFFLE="${SHUFFLE:-no}" SPLASH_PAGE="${SPLASH_PAGE:-yes}" + SYNC_DELETE="${SYNC_DELETE:-yes}" TARBALL_INCLUDE="${TARBALL_INCLUDE:-no}" TARBALL_SUFFIX="${TARBALL_SUFFIX:-.tar}" TAR_TIMEOUT="${TAR_TIMEOUT:-120}" if ! declare -p TAR_OPTS >/dev/null 2>&1; then TAR_OPTS=(-c) fi + if ! declare -p SYNC_DESTINATIONS >/dev/null 2>&1; then + SYNC_DESTINATIONS=() + fi } option_value() { @@ -2291,6 +2353,10 @@ apply_cli_overrides() { for config_target in "${CLI_CONFIG_OVERRIDE_TARGETS[@]}"; do apply_cli_override "$config_target" done + + if (( ${#cli_sync_destinations[@]} > 0 )); then + SYNC_DESTINATIONS=("${cli_sync_destinations[@]}") + fi } config_error() { @@ -2493,9 +2559,42 @@ validate_generation_config() { validate_print_config() { local -a tar_opts=() + local -a sync_destinations=() validate_common_config resolve_tar_opts tar_opts + resolve_sync_destinations sync_destinations + validate_yes_no_config_var SYNC_DELETE +} + +validate_sync_destinations() { + local -a sync_destinations=() + + resolve_sync_destinations sync_destinations + + if (( ${#sync_destinations[@]} == 0 )); then + config_error 'SYNC_DESTINATIONS must contain at least one destination' + fi +} + +validate_rsync() { + if command -v rsync >/dev/null 2>&1; then + return + fi + + config_error 'rsync is required to sync generated output' +} + +validate_sync_config() { + require_config_var DIST_DIR + validate_yes_no_config_var SYNC_DELETE + validate_sync_destinations + + if [[ ! -d "$DIST_DIR" || ! -r "$DIST_DIR" || ! -x "$DIST_DIR" ]]; then + config_error "DIST_DIR $DIST_DIR must be a readable directory" + fi + + validate_rsync } set_cli_option_value() { @@ -2503,6 +2602,12 @@ set_cli_option_value() { local -r value="$1"; shift local config_target + if [ "$option" = --sync-destination ]; then + cli_sync_destinations+=("$value") + has_config_overrides='yes' + return + fi + config_target="${CLI_OPTION_CONFIG_TARGET[$option]:-}" if [ -n "$config_target" ]; then cli_overrides["$config_target"]="$value" @@ -2622,6 +2727,7 @@ log_configured_action() { log_verbose "Effective tar timeout: ${TAR_TIMEOUT:-120}s" log_verbose "Effective splash page setting: ${SPLASH_PAGE:-yes}" log_verbose "Effective tarball setting: ${TARBALL_INCLUDE:-no}" + log_verbose "Effective sync delete setting: ${SYNC_DELETE:-yes}" } run_configured_action() { @@ -2648,6 +2754,10 @@ run_configured_action() { validate_refresh_splash_config refresh_splash ;; + --sync) + validate_sync_config + sync_dist + ;; --dry-run) validate_generation_config no dry_run @@ -2664,7 +2774,7 @@ run_action() { --version|--init) run_simple_action ;; - --clean|--generate|--refresh-splash|--dry-run|--print-config) + --clean|--generate|--refresh-splash|--sync|--dry-run|--print-config) run_configured_action ;; *) @@ -2679,6 +2789,7 @@ main() { local config_file='' local has_config_overrides='no' local -A cli_overrides=() + local -a cli_sync_destinations=() if (( $# == 0 )); then usage diff --git a/src/photoalbum.default.conf b/src/photoalbum.default.conf index 6ce1f6d..6fad282 100644 --- a/src/photoalbum.default.conf +++ b/src/photoalbum.default.conf @@ -32,6 +32,15 @@ TARBALL_SUFFIX=.tar TAR_TIMEOUT=120 TAR_OPTS=(-c) +# Destinations for `photoalbum --sync`. Each destination is passed to rsync +# with DIST_DIR/ as the source, so the generated contents are copied into the +# target directory. Sync deletes removed files from destinations by default. +SYNC_DELETE=yes +SYNC_DESTINATIONS=( +# admin@fishfinger.buetow.org:/var/www/htdocs/example.org/ +# admin@blowfish.buetow.org:/var/www/htdocs/example.org/ +) + # Some debugging options #set -e #set -x diff --git a/src/photoalbum.sh b/src/photoalbum.sh index 0184a22..c215c01 100755 --- a/src/photoalbum.sh +++ b/src/photoalbum.sh @@ -23,6 +23,7 @@ declare -ra CLI_CONFIG_OVERRIDE_TARGETS=( RANDOM_SEED SHUFFLE SPLASH_PAGE + SYNC_DELETE TARBALL_INCLUDE ) declare -Ar CLI_OPTION_KIND=( @@ -42,6 +43,9 @@ declare -Ar CLI_OPTION_KIND=( [--no-splash]=flag [--tarball]=flag [--no-tarball]=flag + [--sync-delete]=flag + [--no-sync-delete]=flag + [--sync-destination]=value [--verbose]=output [--quiet]=output [--version]=action @@ -49,6 +53,7 @@ declare -Ar CLI_OPTION_KIND=( [--clean]=action [--generate]=action [--refresh-splash]=action + [--sync]=action [--dry-run]=action [--print-config]=action ) @@ -64,6 +69,8 @@ declare -Ar CLI_OPTION_VALUE=( [--no-splash]=no [--tarball]=yes [--no-tarball]=no + [--sync-delete]=yes + [--no-sync-delete]=no [--verbose]=verbose [--quiet]=quiet ) @@ -83,9 +90,12 @@ declare -Ar CLI_OPTION_CONFIG_TARGET=( [--no-splash]=SPLASH_PAGE [--tarball]=TARBALL_INCLUDE [--no-tarball]=TARBALL_INCLUDE + [--sync-delete]=SYNC_DELETE + [--no-sync-delete]=SYNC_DELETE ) declare -Ar CLI_OPTION_ARGUMENT=( [--config]=path + [--sync-destination]=destination ) usage() { @@ -93,6 +103,7 @@ usage() { Usage: $0 --generate [--config PATH] [OPTIONS] $0 --refresh-splash [--config PATH] [OPTIONS] + $0 --sync [--config PATH] [OPTIONS] $0 --dry-run [--config PATH] [OPTIONS] $0 --print-config [--config PATH] [OPTIONS] $0 --clean [--config PATH] [OPTIONS] @@ -116,6 +127,9 @@ usage() { --no-shuffle --tarball --no-tarball + --sync-destination DEST + --sync-delete + --no-sync-delete --verbose --quiet USAGE @@ -407,6 +421,29 @@ resolve_tar_opts() { fi } +resolve_sync_destinations() { + local -n destinations_ref="$1"; shift + local destinations_decl + + destinations_ref=() + + if ! destinations_decl=$(declare -p SYNC_DESTINATIONS 2>/dev/null); then + return + fi + + case "$destinations_decl" in + declare\ -a*\ SYNC_DESTINATIONS=*) + destinations_ref=("${SYNC_DESTINATIONS[@]}") + ;; + *) + if [ -n "${SYNC_DESTINATIONS:-}" ]; then + # shellcheck disable=SC2034 + read -r -a destinations_ref <<< "$SYNC_DESTINATIONS" + fi + ;; + esac +} + _html_escape() { local text="$1"; shift @@ -2040,8 +2077,10 @@ print_shell_array_assignment() { print_config() { local -a tar_opts=() + local -a sync_destinations=() resolve_tar_opts tar_opts + resolve_sync_destinations sync_destinations print_shell_assignment CONFIG_SOURCE "${PHOTOALBUM_CONFIG_SOURCE:-}" print_shell_assignment INCOMING_DIR "$INCOMING_DIR" @@ -2060,9 +2099,28 @@ print_config() { print_shell_assignment TARBALL_SUFFIX "${TARBALL_SUFFIX:-.tar}" print_shell_assignment TAR_TIMEOUT "$TAR_TIMEOUT" print_shell_array_assignment TAR_OPTS "${tar_opts[@]}" + print_shell_assignment SYNC_DELETE "${SYNC_DELETE:-yes}" + print_shell_array_assignment SYNC_DESTINATIONS "${sync_destinations[@]}" print_shell_assignment ORIGINAL_BASEPATH "${ORIGINAL_BASEPATH:-}" } +sync_dist() { + local destination + local -a rsync_args=(-av) + local -a sync_destinations=() + + resolve_sync_destinations sync_destinations + + if [ "${SYNC_DELETE:-yes}" = yes ]; then + rsync_args+=(--delete) + fi + + for destination in "${sync_destinations[@]}"; do + log_info "Syncing $DIST_DIR/ to $destination" + rsync "${rsync_args[@]}" "$DIST_DIR/" "$destination" + done +} + existing_parent_dir() { local -r path="$1"; shift local existing_parent @@ -2256,12 +2314,16 @@ apply_config_defaults() { RANDOM_SEED="${RANDOM_SEED:-}" SHUFFLE="${SHUFFLE:-no}" SPLASH_PAGE="${SPLASH_PAGE:-yes}" + SYNC_DELETE="${SYNC_DELETE:-yes}" TARBALL_INCLUDE="${TARBALL_INCLUDE:-no}" TARBALL_SUFFIX="${TARBALL_SUFFIX:-.tar}" TAR_TIMEOUT="${TAR_TIMEOUT:-120}" if ! declare -p TAR_OPTS >/dev/null 2>&1; then TAR_OPTS=(-c) fi + if ! declare -p SYNC_DESTINATIONS >/dev/null 2>&1; then + SYNC_DESTINATIONS=() + fi } option_value() { @@ -2291,6 +2353,10 @@ apply_cli_overrides() { for config_target in "${CLI_CONFIG_OVERRIDE_TARGETS[@]}"; do apply_cli_override "$config_target" done + + if (( ${#cli_sync_destinations[@]} > 0 )); then + SYNC_DESTINATIONS=("${cli_sync_destinations[@]}") + fi } config_error() { @@ -2493,9 +2559,42 @@ validate_generation_config() { validate_print_config() { local -a tar_opts=() + local -a sync_destinations=() validate_common_config resolve_tar_opts tar_opts + resolve_sync_destinations sync_destinations + validate_yes_no_config_var SYNC_DELETE +} + +validate_sync_destinations() { + local -a sync_destinations=() + + resolve_sync_destinations sync_destinations + + if (( ${#sync_destinations[@]} == 0 )); then + config_error 'SYNC_DESTINATIONS must contain at least one destination' + fi +} + +validate_rsync() { + if command -v rsync >/dev/null 2>&1; then + return + fi + + config_error 'rsync is required to sync generated output' +} + +validate_sync_config() { + require_config_var DIST_DIR + validate_yes_no_config_var SYNC_DELETE + validate_sync_destinations + + if [[ ! -d "$DIST_DIR" || ! -r "$DIST_DIR" || ! -x "$DIST_DIR" ]]; then + config_error "DIST_DIR $DIST_DIR must be a readable directory" + fi + + validate_rsync } set_cli_option_value() { @@ -2503,6 +2602,12 @@ set_cli_option_value() { local -r value="$1"; shift local config_target + if [ "$option" = --sync-destination ]; then + cli_sync_destinations+=("$value") + has_config_overrides='yes' + return + fi + config_target="${CLI_OPTION_CONFIG_TARGET[$option]:-}" if [ -n "$config_target" ]; then cli_overrides["$config_target"]="$value" @@ -2622,6 +2727,7 @@ log_configured_action() { log_verbose "Effective tar timeout: ${TAR_TIMEOUT:-120}s" log_verbose "Effective splash page setting: ${SPLASH_PAGE:-yes}" log_verbose "Effective tarball setting: ${TARBALL_INCLUDE:-no}" + log_verbose "Effective sync delete setting: ${SYNC_DELETE:-yes}" } run_configured_action() { @@ -2648,6 +2754,10 @@ run_configured_action() { validate_refresh_splash_config refresh_splash ;; + --sync) + validate_sync_config + sync_dist + ;; --dry-run) validate_generation_config no dry_run @@ -2664,7 +2774,7 @@ run_action() { --version|--init) run_simple_action ;; - --clean|--generate|--refresh-splash|--dry-run|--print-config) + --clean|--generate|--refresh-splash|--sync|--dry-run|--print-config) run_configured_action ;; *) @@ -2679,6 +2789,7 @@ main() { local config_file='' local has_config_overrides='no' local -A cli_overrides=() + local -a cli_sync_destinations=() if (( $# == 0 )); then usage diff --git a/tests/cli.sh b/tests/cli.sh index d6e2787..905f6a4 100755 --- a/tests/cli.sh +++ b/tests/cli.sh @@ -1082,6 +1082,8 @@ TARBALL_INCLUDE=yes TARBALL_SUFFIX=.tar TAR_TIMEOUT=120 TAR_OPTS=( -c ) +SYNC_DELETE=yes +SYNC_DESTINATIONS=( ) ORIGINAL_BASEPATH='' EOF ) @@ -1219,6 +1221,8 @@ TARBALL_INCLUDE=no TARBALL_SUFFIX=.tar TAR_TIMEOUT=120 TAR_OPTS=( -c ) +SYNC_DELETE=yes +SYNC_DESTINATIONS=( ) ORIGINAL_BASEPATH='' EOF ) @@ -1258,6 +1262,8 @@ TARBALL_INCLUDE=no TARBALL_SUFFIX=.tar TAR_TIMEOUT=120 TAR_OPTS=( -c ) +SYNC_DELETE=yes +SYNC_DESTINATIONS=( ) ORIGINAL_BASEPATH='' EOF ) @@ -1322,6 +1328,8 @@ TARBALL_INCLUDE=yes TARBALL_SUFFIX=.tar TAR_TIMEOUT=120 TAR_OPTS=( -c ) +SYNC_DELETE=yes +SYNC_DESTINATIONS=( ) ORIGINAL_BASEPATH='' EOF ) @@ -3057,6 +3065,154 @@ test_generate_handles_space_and_underscore_names_distinctly() { test::teardown } +test_sync_uses_config_destinations_with_delete() { + local config_file + local dist_dir + local fake_bin + local rsync_log + local rsync_output + + test::setup + fake_bin="$TEST_TMPDIR/bin" + config_file="$TEST_TMPDIR/photoalbum.conf" + dist_dir="$TEST_TMPDIR/dist" + rsync_log="$TEST_TMPDIR/rsync.log" + + test::install_rsync_spy "$fake_bin" + mkdir -p "$dist_dir" + printf 'generated\n' > "$dist_dir/index.html" + { + printf 'DIST_DIR=%q\n' "$dist_dir" + printf 'SYNC_DESTINATIONS=(\n' + printf ' %q\n' \ + 'admin@fishfinger.buetow.org:/var/www/htdocs/example.org/' + printf ' %q\n' \ + 'admin@blowfish.buetow.org:/var/www/htdocs/example.org/' + printf ')\n' + } > "$config_file" + + ( + cd "$TEST_TMPDIR" + PATH="$fake_bin:$PATH" TEST_RSYNC_LOG="$rsync_log" \ + "$TEST_PHOTOALBUM" --sync + ) + + rsync_output=$(<"$rsync_log") + test::assert_contains $'argc=4\narg0=-av\narg1=--delete' \ + "$rsync_output" + test::assert_contains "arg2=$dist_dir/" "$rsync_output" + test::assert_contains \ + 'arg3=admin@fishfinger.buetow.org:/var/www/htdocs/example.org/' \ + "$rsync_output" + test::assert_contains \ + 'arg3=admin@blowfish.buetow.org:/var/www/htdocs/example.org/' \ + "$rsync_output" + test::teardown +} + +test_sync_cli_destinations_override_config_without_delete() { + local config_file + local dist_dir + local fake_bin + local rsync_log + local rsync_output + + test::setup + fake_bin="$TEST_TMPDIR/bin" + config_file="$TEST_TMPDIR/photoalbum.conf" + dist_dir="$TEST_TMPDIR/dist" + rsync_log="$TEST_TMPDIR/rsync.log" + + test::install_rsync_spy "$fake_bin" + mkdir -p "$dist_dir" + printf 'generated\n' > "$dist_dir/index.html" + { + printf 'DIST_DIR=%q\n' "$dist_dir" + printf 'SYNC_DESTINATIONS=(%q)\n' \ + 'admin@config.example:/var/www/config/' + } > "$config_file" + + ( + cd "$TEST_TMPDIR" + PATH="$fake_bin:$PATH" TEST_RSYNC_LOG="$rsync_log" \ + "$TEST_PHOTOALBUM" \ + --sync \ + --no-sync-delete \ + --sync-destination 'admin@one.example:/var/www/one/' \ + --sync-destination 'admin@two.example:/var/www/two/' + ) + + rsync_output=$(<"$rsync_log") + test::assert_contains $'argc=3\narg0=-av' "$rsync_output" + test::assert_not_contains '--delete' "$rsync_output" + test::assert_not_contains 'admin@config.example' "$rsync_output" + test::assert_contains "arg1=$dist_dir/" "$rsync_output" + test::assert_contains 'arg2=admin@one.example:/var/www/one/' \ + "$rsync_output" + test::assert_contains 'arg2=admin@two.example:/var/www/two/' \ + "$rsync_output" + test::teardown +} + +test_sync_rejects_empty_destinations() { + local config_file + local dist_dir + local fake_bin + local output + local rsync_log + + test::setup + fake_bin="$TEST_TMPDIR/bin" + config_file="$TEST_TMPDIR/photoalbum.conf" + dist_dir="$TEST_TMPDIR/dist" + rsync_log="$TEST_TMPDIR/rsync.log" + + test::install_rsync_spy "$fake_bin" + mkdir -p "$dist_dir" + printf 'DIST_DIR=%q\n' "$dist_dir" > "$config_file" + + output=$( + cd "$TEST_TMPDIR" + PATH="$fake_bin:$PATH" TEST_RSYNC_LOG="$rsync_log" \ + test::capture_failure_output "$TEST_PHOTOALBUM" --sync + ) + + test::assert_contains \ + 'ERROR: SYNC_DESTINATIONS must contain at least one destination' \ + "$output" + test::assert_path_absent "$rsync_log" + test::teardown +} + +test_sync_rejects_missing_dist() { + local config_file + local fake_bin + local output + + test::setup + fake_bin="$TEST_TMPDIR/bin" + config_file="$TEST_TMPDIR/photoalbum.conf" + + test::install_rsync_spy "$fake_bin" + { + printf 'DIST_DIR=%q\n' "$TEST_TMPDIR/missing-dist" + printf 'SYNC_DESTINATIONS=(%q)\n' \ + 'admin@example.org:/var/www/example.org/' + } > "$config_file" + + output=$( + cd "$TEST_TMPDIR" + PATH="$fake_bin:$PATH" TEST_RSYNC_LOG="$TEST_TMPDIR/rsync.log" \ + test::capture_failure_output "$TEST_PHOTOALBUM" --sync + ) + + test::assert_contains \ + "ERROR: DIST_DIR $TEST_TMPDIR/missing-dist must be a readable directory" \ + "$output" + test::assert_path_absent "$TEST_TMPDIR/rsync.log" + test::teardown +} + test_positional_commands_fail_without_deprecation() { local output local old_command @@ -3113,6 +3269,7 @@ test_missing_option_values_fail() { --maxpreviews --image-jobs --random-seed + --sync-destination ) for option in "${value_options[@]}"; do @@ -3342,6 +3499,18 @@ main() { test::run_case \ '--generate handles spaces and underscores distinctly' \ test_generate_handles_space_and_underscore_names_distinctly + test::run_case \ + '--sync uses config destinations with delete' \ + test_sync_uses_config_destinations_with_delete + test::run_case \ + '--sync CLI destinations override config without delete' \ + test_sync_cli_destinations_override_config_without_delete + test::run_case \ + '--sync rejects empty destinations' \ + test_sync_rejects_empty_destinations + test::run_case \ + '--sync rejects missing dist' \ + test_sync_rejects_missing_dist test::run_case \ 'positional commands fail without deprecation output' \ test_positional_commands_fail_without_deprecation diff --git a/tests/helpers.sh b/tests/helpers.sh index 6f6d507..155cfc0 100755 --- a/tests/helpers.sh +++ b/tests/helpers.sh @@ -479,6 +479,27 @@ TAR chmod 0755 "$bin_dir/tar" } +test::install_rsync_spy() { + local -r bin_dir="$1"; shift + + mkdir -p "$bin_dir" + + cat > "$bin_dir/rsync" <<'RSYNC' +#!/usr/bin/env bash +set -euo pipefail + +{ + printf 'argc=%s\n' "$#" + i=0 + for arg in "$@"; do + printf 'arg%s=%q\n' "$i" "$arg" + i=$(( i + 1 )) + done +} >> "$TEST_RSYNC_LOG" +RSYNC + chmod 0755 "$bin_dir/rsync" +} + test::install_hanging_tar() { local -r bin_dir="$1"; shift -- cgit v1.2.3